# ADR-048: Authentication Strategy
**Date**: 2026-01-09
**Status**: Accepted (Fully Implemented)
**Implemented**: 2026-01-09 (Local auth + JWT), 2026-01-26 (OAuth enabled)
## Context
The application requires a secure authentication system that supports both traditional email/password login and social OAuth providers (Google, GitHub). The system must handle user sessions, token refresh, account security (lockout after failed attempts), and integrate seamlessly with the existing Express middleware pipeline.
**All authentication methods are now fully implemented**: Local authentication (email/password), JWT tokens, and OAuth (Google + GitHub). OAuth strategies use conditional registration - they activate automatically when the corresponding environment variables are configured.
## Decision
We will implement a stateless JWT-based authentication system with the following components:
1. **Local Authentication**: Email/password login with bcrypt hashing.
2. **OAuth Authentication**: Google and GitHub OAuth 2.0 (conditionally enabled via environment variables).
3. **JWT Access Tokens**: Short-lived tokens (15 minutes) for API authentication.
4. **Refresh Tokens**: Long-lived tokens (7 days) stored in HTTP-only cookies.
5. **Account Security**: Lockout after 5 failed login attempts for 15 minutes.
### Design Principles
- **Stateless Sessions**: No server-side session storage; JWT contains all auth state.
- **Defense in Depth**: Multiple security layers (rate limiting, lockout, secure cookies).
- **Graceful OAuth Degradation**: OAuth is optional; system works with local auth only.
- **OAuth User Flexibility**: OAuth users have `password_hash = NULL` in database.
## 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** | 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
### Authentication Flow
```text
┌─────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Login │───>│ Passport │───>│ JWT │───>│ Protected│ │
│ │ Request │ │ Local │ │ Token │ │ Routes │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ │ ┌──────────┐ │ │ │
│ └────────>│ OAuth │─────────────┘ │ │
│ │ Provider │ │ │
│ └──────────┘ │ │
│ │ │
│ ┌──────────┐ ┌──────────┐ │ │
│ │ Refresh │───>│ New │<─────────────────────────┘ │
│ │ Token │ │ JWT │ (when access token expires) │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Local Strategy (Enabled)
Located in `src/routes/passport.routes.ts`:
```typescript
passport.use(
new LocalStrategy(
{ usernameField: 'email', passReqToCallback: true },
async (req, email, password, done) => {
// 1. Find user with profile by email
const userprofile = await db.userRepo.findUserWithProfileByEmail(email, req.log);
// 2. Check account lockout
if (userprofile.failed_login_attempts >= MAX_FAILED_ATTEMPTS) {
// Check if lockout period has passed
}
// 3. Verify password with bcrypt
const isMatch = await bcrypt.compare(password, userprofile.password_hash);
// 4. On success, reset failed attempts and return user
// 5. On failure, increment failed attempts
},
),
);
```
**Security Features**:
- Bcrypt password hashing with salt rounds = 10
- Account lockout after 5 failed attempts
- 15-minute lockout duration
- Failed attempt tracking persists across lockout refreshes
- Activity logging for failed login attempts
### JWT Strategy (Enabled)
```typescript
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: JWT_SECRET,
};
passport.use(
new JwtStrategy(jwtOptions, async (jwt_payload, done) => {
const userProfile = await db.userRepo.findUserProfileById(jwt_payload.user_id);
if (userProfile) {
return done(null, userProfile);
}
return done(null, false);
}),
);
```
**Token Configuration**:
- Access token: 15 minutes expiry
- Refresh token: 7 days expiry, 64-byte random hex
- Refresh token stored in HTTP-only cookie with `secure` flag in production
### OAuth Strategies (Conditionally Enabled)
OAuth strategies are **fully implemented** and activate automatically when the corresponding environment variables are set. The strategies use conditional registration to gracefully handle missing credentials.
#### Google OAuth
Located in `src/config/passport.ts` (lines 167-235):
```typescript
// 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, _refreshToken, profile, done) => {
const email = profile.emails?.[0]?.value;
if (!email) {
return done(new Error('No email found in Google profile.'), false);
}
const existingUserProfile = await db.userRepo.findUserWithProfileByEmail(email, logger);
if (existingUserProfile) {
// User exists, log them in (strip sensitive fields)
return done(null, cleanUserProfile);
} else {
// Create new user with null password_hash for OAuth users
const newUserProfile = await db.userRepo.createUser(
email,
null,
{
full_name: profile.displayName,
avatar_url: profile.photos?.[0]?.value,
},
logger,
);
return done(null, newUserProfile);
}
},
),
);
logger.info('[Passport] Google OAuth strategy registered.');
} else {
logger.warn('[Passport] Google OAuth strategy NOT registered: credentials not set.');
}
```
#### GitHub OAuth
Located in `src/config/passport.ts` (lines 237-310):
```typescript
// 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, _refreshToken, profile, done) => {
const email = profile.emails?.[0]?.value;
if (!email) {
return done(new Error('No public email found in GitHub profile.'), false);
}
// Same flow as Google OAuth - find or create user
},
),
);
logger.info('[Passport] GitHub OAuth strategy registered.');
} else {
logger.warn('[Passport] GitHub OAuth strategy NOT registered: credentials not set.');
}
```
#### OAuth Routes (Active)
Located in `src/routes/auth.routes.ts` (lines 587-609):
```typescript
// 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'),
);
// 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'),
);
```
#### OAuth Callback Handler
The callback handler generates tokens and redirects to the frontend:
```typescript
const createOAuthCallbackHandler = (provider: 'google' | 'github') => {
return async (req: Request, res: Response) => {
const userProfile = req.user as UserProfile;
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
userProfile,
req.log,
);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
// Redirect to frontend with provider-specific token param
const tokenParam = provider === 'google' ? 'googleAuthToken' : 'githubAuthToken';
res.redirect(`${process.env.FRONTEND_URL}/?${tokenParam}=${accessToken}`);
};
};
```
### Database Schema
**Users Table** (`sql/initial_schema.sql`):
```sql
CREATE TABLE public.users (
user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT, -- NULL for OAuth-only users
refresh_token TEXT, -- Current refresh token
failed_login_attempts INTEGER DEFAULT 0,
last_failed_login TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
```
**Note**: There is no separate OAuth provider mapping table. OAuth users are identified by `password_hash = NULL`. If a user signs up via OAuth and later wants to add a password, this would require schema changes.
### Authentication Middleware
Located in `src/routes/passport.routes.ts`:
```typescript
// Require admin role
export const isAdmin = (req, res, next) => {
if (req.user?.role === 'admin') {
next();
} else {
next(new ForbiddenError('Administrator access required.'));
}
};
// Optional auth - attach user if present, continue if not
export const optionalAuth = (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user) => {
if (user) req.user = user;
next();
})(req, res, next);
};
// Mock auth for testing (only in NODE_ENV=test)
export const mockAuth = (req, res, next) => {
if (process.env.NODE_ENV === 'test') {
req.user = createMockUserProfile({ role: 'admin' });
}
next();
};
```
## Configuring OAuth Providers
OAuth is fully implemented and activates automatically when credentials are provided. No code changes are required.
### Step 1: Set Environment Variables
Add to your environment (`.env.local` for development, Gitea secrets for production):
```bash
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# GitHub OAuth
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
```
### Step 2: Configure OAuth Providers
**Google Cloud Console**:
1. Create project at
2. Enable Google+ API
3. Create OAuth 2.0 credentials (Web Application)
4. Add authorized redirect URI:
- Development: `http://localhost:3001/api/auth/google/callback`
- Production: `https://your-domain.com/api/auth/google/callback`
**GitHub Developer Settings**:
1. Go to
2. Create new OAuth App
3. Set Authorization callback URL:
- Development: `http://localhost:3001/api/auth/github/callback`
- Production: `https://your-domain.com/api/auth/github/callback`
### Step 3: Restart the Application
After setting the environment variables, restart PM2:
```bash
podman exec -it flyer-crawler-dev pm2 restart all
```
The Passport configuration will automatically register the OAuth strategies when it detects the credentials. Check the logs for confirmation:
```text
[Passport] Google OAuth strategy registered.
[Passport] GitHub OAuth strategy registered.
```
### Frontend Integration
OAuth login buttons are implemented in `src/client/pages/AuthView.tsx`. The frontend:
1. Redirects users to `/api/auth/google` or `/api/auth/github`
2. Handles the callback via the `useAppInitialization` hook which looks for `googleAuthToken` or `githubAuthToken` query parameters
3. Stores the token and redirects to the dashboard
## Known Limitations
1. **No OAuth Provider ID Mapping**: Users are identified by email only. If a user has accounts with different emails on Google and GitHub, they create separate accounts.
2. **No Account Linking**: Users cannot link multiple OAuth providers to one account.
3. **No Password Addition for OAuth Users**: OAuth-only users cannot add a password to enable local login.
4. **No PKCE Flow**: OAuth implementation uses standard flow, not PKCE (Proof Key for Code Exchange).
5. **No OAuth State Parameter Validation**: The commented code doesn't show explicit state parameter handling for CSRF protection (Passport may handle this internally).
6. **No Refresh Token from OAuth Providers**: Only email/profile data is extracted; OAuth refresh tokens are not stored for API access.
## Dependencies
**Installed** (all available):
- `passport` v0.7.0
- `passport-local` v1.0.0
- `passport-jwt` v4.0.1
- `passport-google-oauth20` v2.0.0
- `passport-github2` v0.1.12
- `bcrypt` v5.x
- `jsonwebtoken` v9.x
**Type Definitions**:
- `@types/passport`
- `@types/passport-local`
- `@types/passport-jwt`
- `@types/passport-google-oauth20`
- `@types/passport-github2`
## Consequences
### Positive
- **Stateless Architecture**: No session storage required; scales horizontally.
- **Secure by Default**: HTTP-only cookies, short token expiry, bcrypt hashing.
- **Account Protection**: Lockout prevents brute-force attacks.
- **Flexible OAuth**: OAuth activates automatically when credentials are set - no code changes needed.
- **Graceful Degradation**: System works with local auth only when OAuth credentials are not configured.
- **Full Feature Set**: Both local and OAuth authentication are production-ready.
### Negative
- **No Account Linking**: Multiple OAuth providers create separate accounts if emails differ.
- **Token in URL**: OAuth callback passes token in URL query parameter (visible in browser history).
- **Email-Based Identity**: OAuth users are identified by email only, not provider-specific IDs.
### Mitigation
- Document OAuth configuration steps clearly (see [../architecture/AUTHENTICATION.md](../architecture/AUTHENTICATION.md)).
- Consider adding OAuth provider ID columns for future account linking.
- Consider using URL fragment (`#token=`) instead of query parameter for callback in future enhancement.
## Key Files
| File | Purpose |
| ------------------------------------------------------ | ------------------------------------------------ |
| `src/config/passport.ts` | Passport strategies (local, JWT, OAuth) |
| `src/routes/auth.routes.ts` | Auth endpoints (login, register, refresh, OAuth) |
| `src/services/authService.ts` | Auth business logic |
| `src/services/db/user.db.ts` | User database operations |
| `src/config/env.ts` | Environment variable validation |
| `src/client/pages/AuthView.tsx` | Frontend login/register UI with OAuth buttons |
| [AUTHENTICATION.md](../architecture/AUTHENTICATION.md) | OAuth setup guide |
| `.env.example` | Environment variable template |
## Related ADRs
- [ADR-011](./0011-advanced-authorization-and-access-control-strategy.md) - Authorization and Access Control
- [ADR-016](./0016-api-security-hardening.md) - API Security (rate limiting, headers)
- [ADR-032](./0032-rate-limiting-strategy.md) - Rate Limiting
- [ADR-043](./0043-express-middleware-pipeline.md) - Middleware Pipeline
## Future Enhancements
1. **Add OAuth Provider Mapping Table**: Store `googleId`, `githubId` for account linking.
2. **Implement Account Linking**: Allow users to connect multiple OAuth providers.
3. **Add Password to OAuth Users**: Allow OAuth users to set a password for local login.
4. **Implement PKCE**: Add PKCE flow for enhanced OAuth security.
5. **Token in Fragment**: Use URL fragment for OAuth callback token instead of query parameter.
6. **OAuth Token Storage**: Store OAuth refresh tokens for provider API access.
7. **Magic Link Login**: Add passwordless email login option.
8. **Additional OAuth Providers**: Support for Apple, Microsoft, or other providers.