google + github oauth
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m39s

This commit is contained in:
2026-01-10 23:57:18 -08:00
parent 5eed3f51f4
commit 2bf4a7c1e6
8 changed files with 236 additions and 133 deletions

View File

@@ -41,6 +41,14 @@ FRONTEND_URL=http://localhost:3000
# REQUIRED: Secret key for signing JWT tokens (generate a random 64+ character string) # REQUIRED: Secret key for signing JWT tokens (generate a random 64+ character string)
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production 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 # AI/ML Services
# =================== # ===================

View File

@@ -130,6 +130,11 @@ jobs:
SMTP_USER: '' SMTP_USER: ''
SMTP_PASS: '' SMTP_PASS: ''
SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com' 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: | run: |
if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then 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." echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set."

View File

@@ -1,5 +1,16 @@
# Claude Code Project Instructions # 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 ## 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. **CRITICAL**: This application is designed to run **exclusively on Linux**. See [ADR-014](docs/adr/0014-containerization-and-deployment-strategy.md) for full details.

View File

@@ -31,17 +31,17 @@ We will implement a stateless JWT-based authentication system with the following
## Current Implementation Status ## Current Implementation Status
| Component | Status | Notes | | Component | Status | Notes |
| ------------------------ | --------------- | ------------------------------------------------ | | ------------------------ | ------- | ----------------------------------------------------------- |
| **Local Authentication** | Enabled | Email/password with bcrypt (salt rounds = 10) | | **Local Authentication** | Enabled | Email/password with bcrypt (salt rounds = 10) |
| **JWT Access Tokens** | Enabled | 15-minute expiry, `Authorization: Bearer` header | | **JWT Access Tokens** | Enabled | 15-minute expiry, `Authorization: Bearer` header |
| **Refresh Tokens** | Enabled | 7-day expiry, HTTP-only cookie | | **Refresh Tokens** | Enabled | 7-day expiry, HTTP-only cookie |
| **Account Lockout** | Enabled | 5 failed attempts, 15-minute lockout | | **Account Lockout** | Enabled | 5 failed attempts, 15-minute lockout |
| **Password Reset** | Enabled | Email-based token flow | | **Password Reset** | Enabled | Email-based token flow |
| **Google OAuth** | Disabled | Code present, commented out | | **Google OAuth** | Enabled | Requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars |
| **GitHub OAuth** | Disabled | Code present, commented out | | **GitHub OAuth** | Enabled | Requires GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET env vars |
| **OAuth Routes** | Disabled | Endpoints commented out | | **OAuth Routes** | Enabled | `/api/auth/google`, `/api/auth/github` + callbacks |
| **OAuth Frontend UI** | Not Implemented | No login buttons exist | | **OAuth Frontend UI** | Enabled | Login buttons in AuthView.tsx |
## Implementation Details ## Implementation Details

View File

@@ -182,8 +182,8 @@ describe('createUploadMiddleware', () => {
); );
}); });
it('should generate a predictable filename in test environment', () => { it('should generate a unique filename in test environment', () => {
// This test covers lines 43-46 // This test covers the default case in getStorageConfig
vi.stubEnv('NODE_ENV', 'test'); vi.stubEnv('NODE_ENV', 'test');
const mockFlyerFile = { const mockFlyerFile = {
fieldname: 'flyerFile', fieldname: 'flyerFile',
@@ -196,7 +196,10 @@ describe('createUploadMiddleware', () => {
storageOptions.filename!(mockReq, mockFlyerFile, cb); 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(mockNext).toHaveBeenCalledWith(err);
expect(mockResponse.status).not.toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -461,9 +461,9 @@ describe('AI Routes (/api/ai)', () => {
expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); // Should not be called if service throws
// Assert that the file was deleted // Assert that the file was deleted
expect(unlinkSpy).toHaveBeenCalledTimes(1); 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(unlinkSpy).toHaveBeenCalledWith(
expect.stringContaining('flyerImage-test-flyer-image.jpg'), expect.stringMatching(/flyerImage-\d+-\d+-test-flyer-image\.jpg/),
); );
}); });

View File

@@ -288,30 +288,65 @@ router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
// --- OAuth Routes --- // --- OAuth Routes ---
// const handleOAuthCallback = (req: Request, res: Response) => { /**
// const user = req.user as { user_id: string; email: string }; * Handles the OAuth callback after successful authentication.
// const payload = { user_id: user.user_id, email: user.email }; * Generates tokens and redirects to the frontend with the access token.
// const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); * @param provider The OAuth provider name ('google' or 'github') for the query param.
// const refreshToken = crypto.randomBytes(64).toString('hex'); */
const createOAuthCallbackHandler = (provider: 'google' | 'github') => {
return async (req: Request, res: Response) => {
const userProfile = req.user as UserProfile;
// db.saveRefreshToken(user.user_id, refreshToken).then(() => { if (!userProfile || !userProfile.user) {
// res.cookie('refreshToken', refreshToken, { req.log.error('OAuth callback received but no user profile found');
// httpOnly: true, return res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`);
// 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`);
// });
// };
// router.get('/google', passport.authenticate('google', { session: false })); try {
// router.get('/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login' }), handleOAuthCallback); const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
userProfile,
req.log,
);
// router.get('/github', passport.authenticate('github', { session: false })); res.cookie('refreshToken', refreshToken, {
// router.get('/github/callback', passport.authenticate('github', { session: false, failureRedirect: '/login' }), handleOAuthCallback); 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; export default router;

View File

@@ -2,8 +2,8 @@
import passport from 'passport'; import passport from 'passport';
// All route handlers now use req.log (request-scoped logger) as per ADR-004 // All route handlers now use req.log (request-scoped logger) as per ADR-004
import { Strategy as LocalStrategy } from 'passport-local'; import { Strategy as LocalStrategy } from 'passport-local';
//import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as GoogleStrategy, Profile as GoogleProfile } from 'passport-google-oauth20';
//import { Strategy as GitHubStrategy } from 'passport-github2'; import { Strategy as GitHubStrategy, Profile as GitHubProfile } from 'passport-github2';
// All route handlers now use req.log (request-scoped logger) as per ADR-004 // All route handlers now use req.log (request-scoped logger) as per ADR-004
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
@@ -165,108 +165,149 @@ passport.use(
); );
// --- Passport Google OAuth 2.0 Strategy --- // --- Passport Google OAuth 2.0 Strategy ---
// passport.use(new GoogleStrategy({ // Only register the strategy if the required environment variables are set.
// clientID: process.env.GOOGLE_CLIENT_ID!, if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
// clientSecret: process.env.GOOGLE_CLIENT_SECRET!, passport.use(
// callbackURL: '/api/auth/google/callback', // Must match the one in Google Cloud Console new GoogleStrategy(
// scope: ['profile', 'email'] {
// }, clientID: process.env.GOOGLE_CLIENT_ID,
// async (accessToken, refreshToken, profile, done) => { clientSecret: process.env.GOOGLE_CLIENT_SECRET,
// try { callbackURL: '/api/auth/google/callback',
// const email = profile.emails?.[0]?.value; scope: ['profile', 'email'],
// if (!email) { },
// return done(new Error("No email found in Google profile."), false); 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 // Check if user already exists in our database
// const user = await db.findUserByEmail(email); // Changed to const as 'user' is not reassigned const existingUserProfile = await db.userRepo.findUserWithProfileByEmail(email, logger);
// if (user) { if (existingUserProfile) {
// // User exists, proceed to log them in. // User exists, proceed to log them in.
// req.log.info(`Google OAuth successful for existing user: ${email}`); logger.info(`Google OAuth successful for existing user: ${email}`);
// // The password_hash is intentionally destructured and discarded for security. // Strip sensitive fields before returning
// const { password_hash, ...userWithoutHash } = user; const {
// return done(null, userWithoutHash); password_hash: _password_hash,
// } else { failed_login_attempts: _failed_login_attempts,
// // User does not exist, create a new account for them. last_failed_login: _last_failed_login,
// req.log.info(`Google OAuth: creating new user for email: ${email}`); 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. // Since this is an OAuth user, they don't have a password.
// // We pass `null` for the password hash. // We pass `null` for the password hash.
// const newUser = await db.createUser(email, null, { const newUserProfile = await db.userRepo.createUser(
// full_name: profile.displayName, email,
// avatar_url: profile.photos?.[0]?.value 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 return done(null, newUserProfile);
// try { }
// await sendWelcomeEmail(email, profile.displayName); } catch (err) {
// } catch (emailError) { logger.error({ error: err }, 'Error during Google authentication strategy');
// req.log.error(`Failed to send welcome email to new Google user ${email}`, { error: emailError }); return done(err as Error, false);
// // Don't block the login flow if email fails. }
// } },
),
// // The `createUser` function returns the user object without the password hash. );
// return done(null, newUser); logger.info('[Passport] Google OAuth strategy registered.');
// } } else {
// } catch (err) { logger.warn(
// req.log.error('Error during Google authentication strategy:', { error: err }); '[Passport] Google OAuth strategy NOT registered: GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET not set.',
// return done(err, false); );
// } }
// }
// ));
// --- Passport GitHub OAuth 2.0 Strategy --- // --- Passport GitHub OAuth 2.0 Strategy ---
// passport.use(new GitHubStrategy({ // Only register the strategy if the required environment variables are set.
// clientID: process.env.GITHUB_CLIENT_ID!, if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
// clientSecret: process.env.GITHUB_CLIENT_SECRET!, passport.use(
// callbackURL: '/api/auth/github/callback', // Must match the one in GitHub OAuth App settings new GitHubStrategy(
// scope: ['user:email'] // Request email access {
// }, clientID: process.env.GITHUB_CLIENT_ID,
// async (accessToken, refreshToken, profile, done) => { clientSecret: process.env.GITHUB_CLIENT_SECRET,
// try { callbackURL: '/api/auth/github/callback',
// const email = profile.emails?.[0]?.value; scope: ['user:email'],
// if (!email) { },
// return done(new Error("No public email found in GitHub profile. Please ensure your primary email is public or add one."), false); 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 // Check if user already exists in our database
// const user = await db.findUserByEmail(email); // Changed to const as 'user' is not reassigned const existingUserProfile = await db.userRepo.findUserWithProfileByEmail(email, logger);
// if (user) { if (existingUserProfile) {
// // User exists, proceed to log them in. // User exists, proceed to log them in.
// req.log.info(`GitHub OAuth successful for existing user: ${email}`); logger.info(`GitHub OAuth successful for existing user: ${email}`);
// // The password_hash is intentionally destructured and discarded for security. // Strip sensitive fields before returning
// const { password_hash, ...userWithoutHash } = user; const {
// return done(null, userWithoutHash); password_hash: _password_hash,
// } else { failed_login_attempts: _failed_login_attempts,
// // User does not exist, create a new account for them. last_failed_login: _last_failed_login,
// req.log.info(`GitHub OAuth: creating new user for email: ${email}`); 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. // Since this is an OAuth user, they don't have a password.
// // We pass `null` for the password hash. // We pass `null` for the password hash.
// const newUser = await db.createUser(email, null, { const newUserProfile = await db.userRepo.createUser(
// full_name: profile.displayName || profile.username, // GitHub profile might not have displayName email,
// avatar_url: profile.photos?.[0]?.value 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 return done(null, newUserProfile);
// try { }
// await sendWelcomeEmail(email, profile.displayName || profile.username); } catch (err) {
// } catch (emailError) { logger.error({ error: err }, 'Error during GitHub authentication strategy');
// req.log.error(`Failed to send welcome email to new GitHub user ${email}`, { error: emailError }); return done(err as Error, false);
// // Don't block the login flow if email fails. }
// } },
),
// // The `createUser` function returns the user object without the password hash. );
// return done(null, newUser); logger.info('[Passport] GitHub OAuth strategy registered.');
// } } else {
// } catch (err) { logger.warn(
// req.log.error('Error during GitHub authentication strategy:', { error: err }); '[Passport] GitHub OAuth strategy NOT registered: GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET not set.',
// return done(err, false); );
// } }
// }
// ));
// --- Passport JWT Strategy (for protecting API routes) --- // --- Passport JWT Strategy (for protecting API routes) ---
const jwtOptions = { const jwtOptions = {