All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m0s
420 lines
16 KiB
Markdown
420 lines
16 KiB
Markdown
# ADR-048: Authentication Strategy
|
|
|
|
**Date**: 2026-01-09
|
|
|
|
**Status**: Partially Implemented
|
|
|
|
**Implemented**: 2026-01-09 (Local auth only)
|
|
|
|
## 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.
|
|
|
|
Currently, **only local authentication is enabled**. OAuth strategies are fully implemented but commented out, pending configuration of OAuth provider credentials.
|
|
|
|
## 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 (currently disabled).
|
|
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** | 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 |
|
|
|
|
## Implementation Details
|
|
|
|
### Authentication Flow
|
|
|
|
```text
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ AUTHENTICATION FLOW │
|
|
├─────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
│ │ Login │───>│ Passport │───>│ JWT │───>│ Protected│ │
|
|
│ │ Request │ │ Local │ │ Token │ │ Routes │ │
|
|
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
|
│ │ │ │ │
|
|
│ │ ┌──────────┐ │ │ │
|
|
│ └────────>│ OAuth │─────────────┘ │ │
|
|
│ (disabled) │ 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 (Disabled)
|
|
|
|
#### Google OAuth
|
|
|
|
Located in `src/routes/passport.routes.ts` (lines 167-217, commented):
|
|
|
|
```typescript
|
|
// 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;
|
|
// const user = await db.findUserByEmail(email);
|
|
// if (user) {
|
|
// return done(null, user);
|
|
// }
|
|
// // Create new user with null password_hash
|
|
// const newUser = await db.createUser(email, null, {
|
|
// full_name: profile.displayName,
|
|
// avatar_url: profile.photos?.[0]?.value
|
|
// });
|
|
// return done(null, newUser);
|
|
// }
|
|
// ));
|
|
```
|
|
|
|
#### GitHub OAuth
|
|
|
|
Located in `src/routes/passport.routes.ts` (lines 219-269, commented):
|
|
|
|
```typescript
|
|
// 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;
|
|
// // Similar flow to Google OAuth
|
|
// }
|
|
// ));
|
|
```
|
|
|
|
#### OAuth Routes (Disabled)
|
|
|
|
Located in `src/routes/auth.routes.ts` (lines 289-315, commented):
|
|
|
|
```typescript
|
|
// const handleOAuthCallback = (req, res) => {
|
|
// const user = req.user;
|
|
// const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
|
// const refreshToken = crypto.randomBytes(64).toString('hex');
|
|
//
|
|
// await db.saveRefreshToken(user.user_id, refreshToken);
|
|
// res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true });
|
|
// res.redirect(`${FRONTEND_URL}/auth/callback?token=${accessToken}`);
|
|
// };
|
|
|
|
// router.get('/google', passport.authenticate('google', { session: false }));
|
|
// router.get('/google/callback', passport.authenticate('google', { ... }), handleOAuthCallback);
|
|
// router.get('/github', passport.authenticate('github', { session: false }));
|
|
// router.get('/github/callback', passport.authenticate('github', { ... }), handleOAuthCallback);
|
|
```
|
|
|
|
### 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();
|
|
};
|
|
```
|
|
|
|
## Enabling OAuth
|
|
|
|
### Step 1: Set Environment Variables
|
|
|
|
Add to `.env`:
|
|
|
|
```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 <https://console.cloud.google.com/>
|
|
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 <https://github.com/settings/developers>
|
|
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: Uncomment Backend Code
|
|
|
|
**In `src/routes/passport.routes.ts`**:
|
|
|
|
1. Uncomment import statements (lines 5-6):
|
|
|
|
```typescript
|
|
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
|
import { Strategy as GitHubStrategy } from 'passport-github2';
|
|
```
|
|
|
|
2. Uncomment Google strategy (lines 167-217)
|
|
3. Uncomment GitHub strategy (lines 219-269)
|
|
|
|
**In `src/routes/auth.routes.ts`**:
|
|
|
|
1. Uncomment `handleOAuthCallback` function (lines 291-309)
|
|
2. Uncomment OAuth routes (lines 311-315)
|
|
|
|
### Step 4: Add Frontend OAuth Buttons
|
|
|
|
Create login buttons that redirect to:
|
|
|
|
- Google: `GET /api/auth/google`
|
|
- GitHub: `GET /api/auth/github`
|
|
|
|
Handle callback at `/auth/callback?token=<accessToken>`:
|
|
|
|
1. Extract token from URL
|
|
2. Store in client-side token storage
|
|
3. Redirect to dashboard
|
|
|
|
### Step 5: Handle OAuth Callback Page
|
|
|
|
Create `src/pages/AuthCallback.tsx`:
|
|
|
|
```typescript
|
|
const AuthCallback = () => {
|
|
const token = new URLSearchParams(location.search).get('token');
|
|
if (token) {
|
|
setToken(token);
|
|
navigate('/dashboard');
|
|
} else {
|
|
navigate('/login?error=auth_failed');
|
|
}
|
|
};
|
|
```
|
|
|
|
## 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**: Can enable/disable OAuth without code changes (just env vars + uncommenting).
|
|
- **Graceful Degradation**: System works with local auth only.
|
|
|
|
### Negative
|
|
|
|
- **OAuth Disabled by Default**: Requires manual uncommenting to enable.
|
|
- **No Account Linking**: Multiple OAuth providers create separate accounts.
|
|
- **Frontend Work Required**: OAuth login buttons don't exist yet.
|
|
- **Token in URL**: OAuth callback passes token in URL (visible in browser history).
|
|
|
|
### Mitigation
|
|
|
|
- Document OAuth enablement steps clearly (see AUTHENTICATION.md).
|
|
- Consider adding OAuth provider ID columns for future account linking.
|
|
- Use URL fragment (`#token=`) instead of query parameter for callback.
|
|
|
|
## Key Files
|
|
|
|
| File | Purpose |
|
|
| ------------------------------- | ------------------------------------------------ |
|
|
| `src/routes/passport.routes.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 |
|
|
| `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. **Enable OAuth**: Uncomment strategies and configure providers.
|
|
2. **Add OAuth Provider Mapping Table**: Store `googleId`, `githubId` for account linking.
|
|
3. **Implement Account Linking**: Allow users to connect multiple OAuth providers.
|
|
4. **Add Password to OAuth Users**: Allow OAuth users to set a password.
|
|
5. **Implement PKCE**: Add PKCE flow for enhanced OAuth security.
|
|
6. **Token in Fragment**: Use URL fragment for OAuth callback token.
|
|
7. **OAuth Token Storage**: Store OAuth refresh tokens for provider API access.
|
|
8. **Magic Link Login**: Add passwordless email login option.
|