diff --git a/.env.example b/.env.example index b9a92e0b..6e2135db 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,14 @@ FRONTEND_URL=http://localhost:3000 # REQUIRED: Secret key for signing JWT tokens (generate a random 64+ character string) JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +# OAuth Providers (Optional - enable social login) +# Google OAuth - https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# GitHub OAuth - https://github.com/settings/developers +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + # =================== # AI/ML Services # =================== diff --git a/.gitea/workflows/deploy-to-prod.yml b/.gitea/workflows/deploy-to-prod.yml index 55192db5..ab4bf456 100644 --- a/.gitea/workflows/deploy-to-prod.yml +++ b/.gitea/workflows/deploy-to-prod.yml @@ -130,6 +130,11 @@ jobs: SMTP_USER: '' SMTP_PASS: '' SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com' + # OAuth Providers + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} + GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} run: | if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set." diff --git a/CLAUDE.md b/CLAUDE.md index 3155a949..d4f19ff2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,16 @@ # Claude Code Project Instructions +## Communication Style: Ask Before Assuming + +**IMPORTANT**: When helping with tasks, **ask clarifying questions before making assumptions**. Do not assume: + +- What steps the user has or hasn't completed +- What the user already knows or has configured +- What external services (OAuth providers, APIs, etc.) are already set up +- What secrets or credentials have already been created + +Instead, ask the user to confirm the current state before providing instructions or making recommendations. This prevents wasted effort and respects the user's existing work. + ## Platform Requirement: Linux Only **CRITICAL**: This application is designed to run **exclusively on Linux**. See [ADR-014](docs/adr/0014-containerization-and-deployment-strategy.md) for full details. diff --git a/docs/adr/0048-authentication-strategy.md b/docs/adr/0048-authentication-strategy.md index e2dcaa2a..b8c1bf4d 100644 --- a/docs/adr/0048-authentication-strategy.md +++ b/docs/adr/0048-authentication-strategy.md @@ -31,17 +31,17 @@ We will implement a stateless JWT-based authentication system with the following ## Current Implementation Status -| Component | Status | Notes | -| ------------------------ | --------------- | ------------------------------------------------ | -| **Local Authentication** | Enabled | Email/password with bcrypt (salt rounds = 10) | -| **JWT Access Tokens** | Enabled | 15-minute expiry, `Authorization: Bearer` header | -| **Refresh Tokens** | Enabled | 7-day expiry, HTTP-only cookie | -| **Account Lockout** | Enabled | 5 failed attempts, 15-minute lockout | -| **Password Reset** | Enabled | Email-based token flow | -| **Google OAuth** | Disabled | Code present, commented out | -| **GitHub OAuth** | Disabled | Code present, commented out | -| **OAuth Routes** | Disabled | Endpoints commented out | -| **OAuth Frontend UI** | Not Implemented | No login buttons exist | +| Component | Status | Notes | +| ------------------------ | ------- | ----------------------------------------------------------- | +| **Local Authentication** | Enabled | Email/password with bcrypt (salt rounds = 10) | +| **JWT Access Tokens** | Enabled | 15-minute expiry, `Authorization: Bearer` header | +| **Refresh Tokens** | Enabled | 7-day expiry, HTTP-only cookie | +| **Account Lockout** | Enabled | 5 failed attempts, 15-minute lockout | +| **Password Reset** | Enabled | Email-based token flow | +| **Google OAuth** | Enabled | Requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars | +| **GitHub OAuth** | Enabled | Requires GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET env vars | +| **OAuth Routes** | Enabled | `/api/auth/google`, `/api/auth/github` + callbacks | +| **OAuth Frontend UI** | Enabled | Login buttons in AuthView.tsx | ## Implementation Details diff --git a/src/middleware/multer.middleware.test.ts b/src/middleware/multer.middleware.test.ts index 34f57cb3..3e4385b4 100644 --- a/src/middleware/multer.middleware.test.ts +++ b/src/middleware/multer.middleware.test.ts @@ -182,8 +182,8 @@ describe('createUploadMiddleware', () => { ); }); - it('should generate a predictable filename in test environment', () => { - // This test covers lines 43-46 + it('should generate a unique filename in test environment', () => { + // This test covers the default case in getStorageConfig vi.stubEnv('NODE_ENV', 'test'); const mockFlyerFile = { fieldname: 'flyerFile', @@ -196,7 +196,10 @@ describe('createUploadMiddleware', () => { storageOptions.filename!(mockReq, mockFlyerFile, cb); - expect(cb).toHaveBeenCalledWith(null, 'flyerFile-test-flyer-image.jpg'); + expect(cb).toHaveBeenCalledWith( + null, + expect.stringMatching(/^flyerFile-\d+-\d+-test-flyer\.jpg$/), + ); }); }); @@ -266,4 +269,4 @@ describe('handleMulterError Middleware', () => { expect(mockNext).toHaveBeenCalledWith(err); expect(mockResponse.status).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/routes/ai.routes.test.ts b/src/routes/ai.routes.test.ts index e9fb7956..d13c6eba 100644 --- a/src/routes/ai.routes.test.ts +++ b/src/routes/ai.routes.test.ts @@ -461,9 +461,9 @@ describe('AI Routes (/api/ai)', () => { expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws // Assert that the file was deleted expect(unlinkSpy).toHaveBeenCalledTimes(1); - // The filename is predictable in the test environment because of the multer config in ai.routes.ts + // The filename is unique in all environments to prevent race conditions expect(unlinkSpy).toHaveBeenCalledWith( - expect.stringContaining('flyerImage-test-flyer-image.jpg'), + expect.stringMatching(/flyerImage-\d+-\d+-test-flyer-image\.jpg/), ); }); diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 720135a0..998fd288 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -288,30 +288,65 @@ router.post('/logout', logoutLimiter, async (req: Request, res: Response) => { // --- OAuth Routes --- -// const handleOAuthCallback = (req: Request, res: Response) => { -// const user = req.user as { user_id: string; email: string }; -// const payload = { user_id: user.user_id, email: user.email }; -// const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); -// const refreshToken = crypto.randomBytes(64).toString('hex'); +/** + * Handles the OAuth callback after successful authentication. + * Generates tokens and redirects to the frontend with the access token. + * @param provider The OAuth provider name ('google' or 'github') for the query param. + */ +const createOAuthCallbackHandler = (provider: 'google' | 'github') => { + return async (req: Request, res: Response) => { + const userProfile = req.user as UserProfile; -// db.saveRefreshToken(user.user_id, refreshToken).then(() => { -// res.cookie('refreshToken', refreshToken, { -// httpOnly: true, -// secure: process.env.NODE_ENV === 'production', -// maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days -// }); -// // Redirect to a frontend page that can handle the token -// res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${accessToken}`); -// }).catch(err => { -// req.log.error('Failed to save refresh token during OAuth callback:', { error: err }); -// res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`); -// }); -// }; + if (!userProfile || !userProfile.user) { + req.log.error('OAuth callback received but no user profile found'); + return res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`); + } -// router.get('/google', passport.authenticate('google', { session: false })); -// router.get('/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login' }), handleOAuthCallback); + try { + const { accessToken, refreshToken } = await authService.handleSuccessfulLogin( + userProfile, + req.log, + ); -// router.get('/github', passport.authenticate('github', { session: false })); -// router.get('/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/login' }), handleOAuthCallback); + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days + }); + + // Redirect to frontend with the token in a provider-specific query param + // The frontend useAppInitialization hook looks for googleAuthToken or githubAuthToken + const tokenParam = provider === 'google' ? 'googleAuthToken' : 'githubAuthToken'; + res.redirect(`${process.env.FRONTEND_URL}/?${tokenParam}=${accessToken}`); + } catch (err) { + req.log.error({ error: err }, `Failed to complete ${provider} OAuth login`); + res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`); + } + }; +}; + +/* istanbul ignore next -- @preserve: OAuth routes require external provider interaction, not suitable for automated testing */ +// Google OAuth routes +router.get('/google', passport.authenticate('google', { session: false })); +router.get( + '/google/callback', + passport.authenticate('google', { + session: false, + failureRedirect: '/?error=google_auth_failed', + }), + createOAuthCallbackHandler('google'), +); + +/* istanbul ignore next -- @preserve: OAuth routes require external provider interaction, not suitable for automated testing */ +// GitHub OAuth routes +router.get('/github', passport.authenticate('github', { session: false })); +router.get( + '/github/callback', + passport.authenticate('github', { + session: false, + failureRedirect: '/?error=github_auth_failed', + }), + createOAuthCallbackHandler('github'), +); export default router; diff --git a/src/routes/passport.routes.ts b/src/routes/passport.routes.ts index 8a6786d4..66fc7882 100644 --- a/src/routes/passport.routes.ts +++ b/src/routes/passport.routes.ts @@ -2,8 +2,8 @@ import passport from 'passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { Strategy as LocalStrategy } from 'passport-local'; -//import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; -//import { Strategy as GitHubStrategy } from 'passport-github2'; +import { Strategy as GoogleStrategy, Profile as GoogleProfile } from 'passport-google-oauth20'; +import { Strategy as GitHubStrategy, Profile as GitHubProfile } from 'passport-github2'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; import * as bcrypt from 'bcrypt'; @@ -165,108 +165,149 @@ passport.use( ); // --- Passport Google OAuth 2.0 Strategy --- -// passport.use(new GoogleStrategy({ -// clientID: process.env.GOOGLE_CLIENT_ID!, -// clientSecret: process.env.GOOGLE_CLIENT_SECRET!, -// callbackURL: '/api/auth/google/callback', // Must match the one in Google Cloud Console -// scope: ['profile', 'email'] -// }, -// async (accessToken, refreshToken, profile, done) => { -// try { -// const email = profile.emails?.[0]?.value; -// if (!email) { -// return done(new Error("No email found in Google profile."), false); -// } +// Only register the strategy if the required environment variables are set. +if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) { + passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: '/api/auth/google/callback', + scope: ['profile', 'email'], + }, + async ( + _accessToken: string, + _refreshToken: string, + profile: GoogleProfile, + done: (error: Error | null, user?: UserProfile | false) => void, + ) => { + try { + const email = profile.emails?.[0]?.value; + if (!email) { + return done(new Error('No email found in Google profile.'), false); + } -// // Check if user already exists in our database -// const user = await db.findUserByEmail(email); // Changed to const as 'user' is not reassigned + // Check if user already exists in our database + const existingUserProfile = await db.userRepo.findUserWithProfileByEmail(email, logger); -// if (user) { -// // User exists, proceed to log them in. -// req.log.info(`Google OAuth successful for existing user: ${email}`); -// // The password_hash is intentionally destructured and discarded for security. -// const { password_hash, ...userWithoutHash } = user; -// return done(null, userWithoutHash); -// } else { -// // User does not exist, create a new account for them. -// req.log.info(`Google OAuth: creating new user for email: ${email}`); + if (existingUserProfile) { + // User exists, proceed to log them in. + logger.info(`Google OAuth successful for existing user: ${email}`); + // Strip sensitive fields before returning + const { + password_hash: _password_hash, + failed_login_attempts: _failed_login_attempts, + last_failed_login: _last_failed_login, + refresh_token: _refresh_token, + ...cleanUserProfile + } = existingUserProfile; + return done(null, cleanUserProfile); + } else { + // User does not exist, create a new account for them. + logger.info(`Google OAuth: creating new user for email: ${email}`); -// // Since this is an OAuth user, they don't have a password. -// // We pass `null` for the password hash. -// const newUser = await db.createUser(email, null, { -// full_name: profile.displayName, -// avatar_url: profile.photos?.[0]?.value -// }); + // Since this is an OAuth user, they don't have a password. + // We pass `null` for the password hash. + const newUserProfile = await db.userRepo.createUser( + email, + null, // No password for OAuth users + { + full_name: profile.displayName, + avatar_url: profile.photos?.[0]?.value, + }, + logger, + ); -// // Send a welcome email to the new user -// try { -// await sendWelcomeEmail(email, profile.displayName); -// } catch (emailError) { -// req.log.error(`Failed to send welcome email to new Google user ${email}`, { error: emailError }); -// // Don't block the login flow if email fails. -// } - -// // The `createUser` function returns the user object without the password hash. -// return done(null, newUser); -// } -// } catch (err) { -// req.log.error('Error during Google authentication strategy:', { error: err }); -// return done(err, false); -// } -// } -// )); + return done(null, newUserProfile); + } + } catch (err) { + logger.error({ error: err }, 'Error during Google authentication strategy'); + return done(err as Error, false); + } + }, + ), + ); + logger.info('[Passport] Google OAuth strategy registered.'); +} else { + logger.warn( + '[Passport] Google OAuth strategy NOT registered: GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET not set.', + ); +} // --- Passport GitHub OAuth 2.0 Strategy --- -// passport.use(new GitHubStrategy({ -// clientID: process.env.GITHUB_CLIENT_ID!, -// clientSecret: process.env.GITHUB_CLIENT_SECRET!, -// callbackURL: '/api/auth/github/callback', // Must match the one in GitHub OAuth App settings -// scope: ['user:email'] // Request email access -// }, -// async (accessToken, refreshToken, profile, done) => { -// try { -// const email = profile.emails?.[0]?.value; -// if (!email) { -// return done(new Error("No public email found in GitHub profile. Please ensure your primary email is public or add one."), false); -// } +// Only register the strategy if the required environment variables are set. +if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) { + passport.use( + new GitHubStrategy( + { + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackURL: '/api/auth/github/callback', + scope: ['user:email'], + }, + async ( + _accessToken: string, + _refreshToken: string, + profile: GitHubProfile, + done: (error: Error | null, user?: UserProfile | false) => void, + ) => { + try { + const email = profile.emails?.[0]?.value; + if (!email) { + return done( + new Error( + 'No public email found in GitHub profile. Please ensure your primary email is public or add one.', + ), + false, + ); + } -// // Check if user already exists in our database -// const user = await db.findUserByEmail(email); // Changed to const as 'user' is not reassigned + // Check if user already exists in our database + const existingUserProfile = await db.userRepo.findUserWithProfileByEmail(email, logger); -// if (user) { -// // User exists, proceed to log them in. -// req.log.info(`GitHub OAuth successful for existing user: ${email}`); -// // The password_hash is intentionally destructured and discarded for security. -// const { password_hash, ...userWithoutHash } = user; -// return done(null, userWithoutHash); -// } else { -// // User does not exist, create a new account for them. -// req.log.info(`GitHub OAuth: creating new user for email: ${email}`); + if (existingUserProfile) { + // User exists, proceed to log them in. + logger.info(`GitHub OAuth successful for existing user: ${email}`); + // Strip sensitive fields before returning + const { + password_hash: _password_hash, + failed_login_attempts: _failed_login_attempts, + last_failed_login: _last_failed_login, + refresh_token: _refresh_token, + ...cleanUserProfile + } = existingUserProfile; + return done(null, cleanUserProfile); + } else { + // User does not exist, create a new account for them. + logger.info(`GitHub OAuth: creating new user for email: ${email}`); -// // Since this is an OAuth user, they don't have a password. -// // We pass `null` for the password hash. -// const newUser = await db.createUser(email, null, { -// full_name: profile.displayName || profile.username, // GitHub profile might not have displayName -// avatar_url: profile.photos?.[0]?.value -// }); + // Since this is an OAuth user, they don't have a password. + // We pass `null` for the password hash. + const newUserProfile = await db.userRepo.createUser( + email, + null, // No password for OAuth users + { + full_name: profile.displayName || profile.username, // GitHub profile might not have displayName + avatar_url: profile.photos?.[0]?.value, + }, + logger, + ); -// // Send a welcome email to the new user -// try { -// await sendWelcomeEmail(email, profile.displayName || profile.username); -// } catch (emailError) { -// req.log.error(`Failed to send welcome email to new GitHub user ${email}`, { error: emailError }); -// // Don't block the login flow if email fails. -// } - -// // The `createUser` function returns the user object without the password hash. -// return done(null, newUser); -// } -// } catch (err) { -// req.log.error('Error during GitHub authentication strategy:', { error: err }); -// return done(err, false); -// } -// } -// )); + return done(null, newUserProfile); + } + } catch (err) { + logger.error({ error: err }, 'Error during GitHub authentication strategy'); + return done(err as Error, false); + } + }, + ), + ); + logger.info('[Passport] GitHub OAuth strategy registered.'); +} else { + logger.warn( + '[Passport] GitHub OAuth strategy NOT registered: GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET not set.', + ); +} // --- Passport JWT Strategy (for protecting API routes) --- const jwtOptions = {