Add AUTH.md documenting authentication implementation
Some checks failed
Deploy Apartment API / deploy (push) Failing after 1s

- Document completed Google OAuth authentication system
- Include implementation details, security measures, and troubleshooting
- Track future enhancements (admin dashboard, Apple Sign-In, partial public access)
This commit is contained in:
2026-01-22 08:26:17 -07:00
parent ef4ddc0de0
commit d6e56a6e40

717
AUTH.md Normal file
View File

@ -0,0 +1,717 @@
# Google OAuth Authentication Implementation
## Overview
This document details the Google OAuth authentication implementation for the Apartment Dashboard application. **All pages and API endpoints require authentication** - unauthenticated users are redirected to a login page.
> **Status:** Core authentication is complete and deployed. Admin dashboard and future enhancements are documented but not yet implemented.
---
## Table of Contents
0. [Project Context](#0-project-context)
1. [Implementation Status](#1-implementation-status)
2. [Authentication Scope](#2-authentication-scope)
3. [Technical Architecture](#3-technical-architecture)
4. [Implementation Details](#4-implementation-details)
5. [User Activity Logging](#5-user-activity-logging)
6. [Security Measures](#6-security-measures)
7. [Deployment Configuration](#7-deployment-configuration)
8. [Test Checklist](#8-test-checklist)
9. [Future: Admin Dashboard](#9-future-admin-dashboard)
10. [Future: Adding Apple Sign-In](#10-future-adding-apple-sign-in)
11. [Future: Partial Public Access](#11-future-partial-public-access)
---
## 0. Project Context
### Project Structure
```
TowersPriceWebApp/
├── apartment-dashboard/ # Frontend (React/Vite)
│ ├── src/
│ │ ├── App.jsx # Main app (~1300 lines)
│ │ ├── main.jsx # Entry point (wrapped with AuthProvider)
│ │ ├── index.css # Tailwind imports
│ │ ├── context/
│ │ │ └── AuthContext.jsx # Auth state management
│ │ ├── hooks/
│ │ │ ├── useAuth.js # Auth hook
│ │ │ └── useActivityTracker.js # Activity tracking hook
│ │ ├── pages/
│ │ │ └── Login.jsx # Login page with Google button
│ │ └── components/
│ │ └── ProtectedRoute.jsx # Route guard component
│ ├── vite.config.js
│ ├── package.json
│ ├── Dockerfile
│ └── nginx.conf
│
├── apartment-dashboard-api/ # Backend (Express.js)
│ ├── server.js # Main server (~1700 lines)
│ ├── config/
│ │ └── auth.js # JWT, cookie, OAuth config
│ ├── models/
│ │ └── user.js # User CRUD operations
│ ├── middleware/
│ │ ├── passport.js # Google OAuth strategy
│ │ └── auth.js # JWT verification middleware
│ ├── routes/
│ │ ├── auth.js # OAuth routes
│ │ └── activity.js # Activity logging routes
│ ├── services/
│ │ └── activityLogger.js # Activity logging service
│ ├── .env.example # Environment template
│ ├── .dockerignore # Prevents secrets in image
│ ├── package.json
│ ├── Dockerfile
│ └── docker-compose.yml
│
└── AUTH.md # This file
```
### Tech Stack
| Layer | Technology | Version |
|-------|------------|---------|
| Frontend Framework | React | 19.1.0 |
| Frontend Build | Vite | 7.0.4 |
| Frontend Routing | React Router DOM | 7.12.0 |
| Frontend Styling | Tailwind CSS | 3.4.17 |
| Frontend Charts | Recharts | 3.1.0 |
| Backend Framework | Express.js | 4.18.2 |
| Database | MongoDB | 6.3.0 (driver) |
| Auth Library | Passport.js | passport-google-oauth20 |
| Token Management | jsonwebtoken | HTTP-only cookies |
| Reverse Proxy | Traefik | (with Let's Encrypt SSL) |
| Static Server | Nginx | (serves built React app) |
### URLs and Domains
| Environment | Frontend URL | API URL |
|-------------|--------------|---------|
| Production | `https://apartments.maverickapplications.com` | `https://apartments.maverickapplications.com/api` |
| Development | `http://localhost:5173` (Vite) | `http://localhost:3000` |
### MongoDB Details
| Setting | Value |
|---------|-------|
| Database name | `apartments` |
| Data collections | `units_migration_test`, `unit_prices_migration_test`, `daily_summaries` |
| Auth collections | `users`, `user_activity` |
---
## 1. Implementation Status
### Completed
| Feature | Status | Notes |
|---------|--------|-------|
| Google OAuth login | Done | Passport.js with state parameter CSRF protection |
| JWT in HTTP-only cookies | Done | 7-day expiry with sliding window refresh |
| User model with upsert | Done | findOneAndUpdate with $setOnInsert pattern |
| Protected API endpoints | Done | All data endpoints require valid JWT |
| Activity logging | Done | Configurable levels, TTL indexes for cleanup |
| Frontend auth context | Done | React Context with useAuth hook |
| Protected routes | Done | ProtectedRoute component with redirect |
| Login page | Done | Google Sign-In button with error display |
| Security hardening | Done | OAuth state, input validation, .dockerignore |
| Docker deployment | Done | env_file configuration, production settings |
### Not Yet Implemented
| Feature | Priority | Notes |
|---------|----------|-------|
| Admin dashboard | Low | User management, activity viewer |
| Apple Sign-In | Future | Requires Apple Developer account |
| Partial public access | Future | Blurred preview for unauthenticated users |
---
## 2. Authentication Scope
### What Requires Authentication
**All pages and API endpoints require authentication.** Unauthenticated users see only the login page.
| Page/Feature | Unauthenticated | Authenticated |
|--------------|-----------------|---------------|
| Login Page (`/login`) | Accessible | Redirects to `/` |
| Overview Page (`/`) | Redirect to login | Fully accessible |
| Units Page (`/units`) | Redirect to login | Fully accessible |
| Analytics Page (`/analytics`) | Redirect to login | Fully accessible |
| All API Endpoints | 401 Unauthorized | Accessible |
### Protected API Endpoints
```
GET /api/health → Public (health check only)
GET /api/daily-summary → Protected
GET /api/price-history → Protected
GET /api/available-units → Protected
GET /api/recent-activity → Protected
GET /api/plan-stats → Protected
GET /api/unit/:code/history → Protected
GET /api/analytics → Protected
GET /api/best-deals → Protected
GET /api/price-drops → Protected
GET /api/stale-inventory → Protected
GET /api/market-insights → Protected
```
### Auth Routes
```
GET /auth/google → Initiates OAuth flow (sets state cookie)
GET /auth/google/callback → Handles callback (validates state, sets JWT)
GET /auth/me → Returns current user info
GET /auth/status → Returns { authenticated: true/false }
POST /auth/logout → Clears auth cookie
```
---
## 3. Technical Architecture
### Authentication Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Google OAuth Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User clicks "Sign in with Google" │
│ │ │
│ ▼ │
│ 2. Frontend redirects to: /api/auth/google │
│ │ │
│ ▼ │
│ 3. Backend generates state parameter, stores in cookie │
│ │ │
│ ▼ │
│ 4. Backend redirects to Google's OAuth consent screen │
│ │ │
│ ▼ │
│ 5. User authenticates with Google │
│ │ │
│ ▼ │
│ 6. Google redirects to: /api/auth/google/callback?code=XXX&state=YYY │
│ │ │
│ ▼ │
│ 7. Backend validates state parameter against cookie (CSRF protection) │
│ │ │
│ ▼ │
│ 8. Backend exchanges code for tokens, fetches user profile │
│ │ │
│ ▼ │
│ 9. Backend creates/updates user in MongoDB (upsert) │
│ │ │
│ ▼ │
│ 10. Backend creates JWT, sets HTTP-only cookie │
│ │ │
│ ▼ │
│ 11. Backend redirects to frontend │
│ │ │
│ ▼ │
│ 12. Frontend AuthContext checks /auth/status, renders authenticated UI │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Token Refresh Strategy (Sliding Window)
Active users get their tokens refreshed automatically to avoid abrupt logouts.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Sliding Window Refresh │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User makes authenticated request │
│ │ │
│ ▼ │
│ 2. Auth middleware validates JWT │
│ │ │
│ ▼ │
│ 3. Check: Is user still active? (isActive field) │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ No ▼ Yes ▼ │
│ Clear cookie 4. Check token age: (now - iat) > 1 day? │
│ Return 401 │ │
│ ┌────┴────┐ │
│ │ │ │
│ No ▼ Yes ▼ │
│ Continue 5. Issue new JWT with fresh 7-day expiry │
│ normally │ │
│ ▼ │
│ 6. Set new cookie in response │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
| Scenario | Token Age | Action |
|----------|-----------|--------|
| User active daily | < 1 day since last refresh | No refresh |
| User returns after 2 days | 2 days old | Refresh token |
| User returns after 8 days | Expired | 401, redirect to login |
---
## 4. Implementation Details
### Auth Configuration (`config/auth.js`)
```javascript
module.exports = {
google: {
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: ['profile', 'email']
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d',
refreshThreshold: 24 * 60 * 60 // 1 day in seconds
},
cookie: {
name: 'auth_token',
options: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
domain: process.env.NODE_ENV === 'production'
? '.maverickapplications.com'
: undefined
}
}
};
```
### User Model (`models/user.js`)
Uses MongoDB native driver with upsert pattern:
```javascript
async function findOrCreateUser(db, profile) {
const now = new Date();
const result = await db.collection('users').findOneAndUpdate(
{ googleId: profile.googleId },
{
$set: {
email: profile.email,
name: profile.name,
picture: profile.picture || null,
lastLoginAt: now
},
$inc: { loginCount: 1 },
$setOnInsert: {
googleId: profile.googleId,
isActive: true,
role: 'user',
createdAt: now
}
},
{ upsert: true, returnDocument: 'after' }
);
return result;
}
```
**Note:** `loginCount` must NOT be in `$setOnInsert` - it conflicts with `$inc`.
### Passport Strategy (`middleware/passport.js`)
Safely extracts profile data from Google:
```javascript
async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
if (!email) {
return done(new Error('No email found in Google profile'), null);
}
const userProfile = {
googleId: profile.id,
email: email,
name: profile.displayName,
picture: profile.photos?.[0]?.value || null
};
const user = await findOrCreateUser(db, userProfile);
done(null, user);
} catch (error) {
console.error('Passport strategy error:', error);
done(error, null);
}
}
```
### OAuth State Validation (`routes/auth.js`)
CSRF protection using state parameter:
```javascript
// Initiate OAuth - generate and store state
router.get('/google', (req, res, next) => {
const state = crypto.randomBytes(32).toString('hex');
res.cookie('oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 5 * 60 * 1000 // 5 minutes
});
passport.authenticate('google', {
scope: authConfig.google.scope,
session: false,
state: state
})(req, res, next);
});
// Callback - validate state before processing
router.get('/google/callback', (req, res, next) => {
const stateFromCookie = req.cookies.oauth_state;
const stateFromQuery = req.query.state;
res.clearCookie('oauth_state');
if (!stateFromCookie || !stateFromQuery || stateFromCookie !== stateFromQuery) {
return res.redirect(`${process.env.FRONTEND_URL}/login?error=invalid_state`);
}
// ... proceed with authentication
});
```
### Frontend Auth Context (`context/AuthContext.jsx`)
```javascript
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
const response = await fetch(`${API_BASE}/auth/status`, {
credentials: 'include'
});
const data = await response.json();
if (data.authenticated) {
setUser(data.user);
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setLoading(false);
}
};
const login = () => {
window.location.href = `${API_BASE}/auth/google`;
};
const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
```
### Database Indexes
Created automatically on server startup:
**Users Collection:**
- `{ googleId: 1 }` - unique
- `{ email: 1 }` - unique
- `{ isActive: 1, lastLoginAt: -1 }` - compound for queries
**User Activity Collection:**
- `{ userId: 1, timestamp: -1 }` - user activity queries
- `{ timestamp: 1 }` - TTL index (90-day auto-cleanup)
- `{ action: 1, timestamp: -1 }` - action type queries
- `{ sessionId: 1 }` - session grouping
---
## 5. User Activity Logging
### Configuration
Controlled by `ACTIVITY_LOG_LEVEL` environment variable:
| Level | What's Logged |
|-------|---------------|
| `all` | All user interactions (default) |
| `navigation` | Only page views and route changes |
| `none` | Logging disabled |
### Activity Types
| Action | When Logged | Metadata |
|--------|-------------|----------|
| `LOGIN` | User completes OAuth | `{ method: 'google' }` |
| `LOGOUT` | User logs out | `{}` |
| `PAGE_VIEW` | Page load | `{ path }` |
| `NAVIGATION` | Route change | `{ from, to }` |
### Activity Schema
```javascript
{
_id: ObjectId,
userId: ObjectId,
sessionId: String, // UUID for grouping
action: String,
metadata: Object,
page: String,
timestamp: Date,
userAgent: String,
ip: String
}
```
### Auto-Cleanup
Activity records are automatically deleted after 90 days via MongoDB TTL index.
---
## 6. Security Measures
### Implemented
| Security Feature | Implementation |
|------------------|----------------|
| OAuth state parameter | Random 32-byte hex, stored in cookie, validated on callback |
| HTTP-only cookies | JWT not accessible via JavaScript |
| Secure cookies (production) | Only sent over HTTPS |
| SameSite=Lax | CSRF protection for cookies |
| Input validation | Unit codes validated with regex pattern |
| isActive check | Disabled users rejected on every request |
| .dockerignore | Prevents .env and secrets from being copied into images |
| Environment validation | Server exits if MONGO_URI missing in production |
### Input Validation
```javascript
const isValidUnitCode = (unitCode) => {
return typeof unitCode === 'string' &&
unitCode.length > 0 &&
unitCode.length <= 20 &&
/^[A-Za-z0-9_-]+$/.test(unitCode);
};
```
### CORS Configuration
```javascript
const corsOptions = {
origin: process.env.FRONTEND_URL, // Exact origin, not '*'
credentials: true, // Required for cookies
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['set-cookie']
};
```
---
## 7. Deployment Configuration
### Environment Variables
Backend `.env` (see `.env.example` for template):
```env
# MongoDB
MONGO_URI=mongodb+srv://...
# Server
PORT=8080
NODE_ENV=production
# Google OAuth
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxx
GOOGLE_CALLBACK_URL=https://apartments.maverickapplications.com/api/auth/google/callback
# JWT (generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
JWT_SECRET=your-secure-random-string
# App
FRONTEND_URL=https://apartments.maverickapplications.com
# Activity Logging
ACTIVITY_LOG_LEVEL=all
```
### Docker Compose
```yaml
services:
apartment-api:
build: .
env_file:
- .env
environment:
- NODE_ENV=production
```
### Google Cloud Console Setup
Required redirect URIs:
```
https://apartments.maverickapplications.com/api/auth/google/callback
```
OAuth consent screen:
- User type: External
- Scopes: `email`, `profile` (non-sensitive)
---
## 8. Test Checklist
### Completed Tests
- [x] Google OAuth login flow works end-to-end
- [x] JWT cookie is set with correct flags (httpOnly, secure, sameSite)
- [x] Protected endpoints return 401 without valid token
- [x] User data persists across sessions (cookie survives browser close)
- [x] Activity logging captures LOGIN/LOGOUT events
- [x] Data loads correctly regardless of server timezone
- [x] OAuth state parameter prevents CSRF
- [x] User upsert creates new users and updates existing
- [x] Sliding window token refresh extends sessions
- [x] Logout clears cookie and redirects to login
### Production Verification
- [x] OAuth redirect URIs match production domain
- [x] HTTPS enforced
- [x] Environment variables properly set
- [x] MongoDB indexes created on startup
- [x] No sensitive data in console logs
- [x] .dockerignore prevents secrets in image
---
## 9. Future: Admin Dashboard
**Priority: Low**
### Planned Features
1. **User Management**
- View all registered users
- See last login, login count
- Enable/disable users
2. **Activity Viewer**
- Recent activity stream
- Filter by user, action, date
3. **Usage Statistics**
- Active users (daily/weekly/monthly)
- Most common actions
- Peak usage times
### Access Control
- Add `role: 'admin'` to user document
- Create `requireAdmin` middleware
- Manually set initial admin via database
---
## 10. Future: Adding Apple Sign-In
**Priority: Future**
### Requirements
1. Apple Developer account ($99/year)
2. Service ID for web authentication
3. Private key for token verification
### Changes Needed
1. Install `passport-apple`
2. Add Apple strategy to passport
3. Add routes: `/auth/apple`, `/auth/apple/callback`
4. Update user model for multiple providers
5. Add "Sign in with Apple" button
---
## 11. Future: Partial Public Access
**Priority: Future**
Allow unauthenticated users to see a blurred preview of the overview page.
### Behavior
- Summary cards visible
- Charts and detailed data blurred with CSS
- Overlay with "Sign in to view" prompt
### Implementation
1. Make `/api/daily-summary` public
2. Create `AuthBlurOverlay` component
3. Wrap protected sections on overview page
---
## Decisions Log
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Token storage | HTTP-only cookie | More secure than localStorage |
| Token expiry | 7 days with sliding refresh | Balance security and UX |
| Disabled user handling | Silent logout | No error messaging needed |
| State parameter | Random 32-byte hex | Industry standard CSRF protection |
| Activity cleanup | 90-day TTL | Automatic via MongoDB index |
| Date handling | Use latest database date | Handles server timezone mismatch |
| User upsert | findOneAndUpdate | Atomic create/update operation |
---
## Troubleshooting
### Common Issues
| Issue | Cause | Solution |
|-------|-------|----------|
| 401 on all endpoints | Missing/invalid JWT | Check cookie is set, not expired |
| OAuth callback 500 | Profile field access error | Use optional chaining on profile fields |
| Empty data arrays | Timezone mismatch | Server uses latest date from database |
| MongoDB conflict error | loginCount in $setOnInsert | Remove from $setOnInsert, use only $inc |
| Cookie not set | CORS misconfiguration | Ensure credentials: 'include' on all fetches |
| State validation fails | Cookie expired or cleared | State cookie has 5-minute lifetime |