# Authentication System Complete documentation for the Tour Builder Platform authentication system including local auth, OAuth, JWT tokens, and security features. ## Overview The platform uses a multi-strategy authentication system built on: - **Passport.js** - Authentication middleware with JWT, Google OAuth, and Microsoft OAuth strategies - **JWT Tokens** - 6-hour expiration, Bearer token in Authorization header - **bcrypt** - Password hashing with 12 salt rounds - **Rate Limiting** - In-memory rate limiting for auth endpoints ## Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ Frontend │ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ │ Login Page │ │ authSlice.ts │ │ Axios Interceptors │ │ │ │ Register │ │ (Redux) │ │ - Token injection │ │ │ │ Forgot │ │ │ │ - 401 handling │ │ │ └─────────────┘ └──────────────┘ └────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Backend │ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ │ auth.js │ │ AuthService │ │ Passport.js Strategies │ │ │ │ (routes) │ │ (business) │ │ - JWT │ │ │ │ │ │ │ │ - Google OAuth │ │ │ │ │ │ │ │ - Microsoft OAuth │ │ │ └─────────────┘ └──────────────┘ └────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────┐│ │ │ UsersDBApi ││ │ │ - Token generation (email verification, password reset) ││ │ │ - User CRUD operations ││ │ └─────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────┘ ``` ## Passport.js Strategies ### JWT Strategy **File:** `backend/src/auth/auth.ts` ```javascript passport.use(new JWTstrategy({ passReqToCallback: true, secretOrKey: config.secret_key, jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken() }, async (req, token, done) => { // Validates token and checks if user is disabled // Sets req.currentUser for authenticated requests })); ``` | Property | Value | |----------|-------| | Token Location | `Authorization: Bearer {token}` header | | Secret Key | `process.env.SECRET_KEY` | | Token Expiration | 6 hours | | Validation | Checks user exists and is not disabled | ### Google OAuth Strategy ```javascript passport.use(new GoogleStrategy({ clientID: config.google.clientId, clientSecret: config.google.clientSecret, callbackURL: config.apiUrl + '/auth/signin/google/callback', passReqToCallback: true })); ``` | Property | Value | |----------|-------| | Scopes | `profile`, `email` (defined in routes when calling `passport.authenticate()`) | | Callback URL | `/api/auth/signin/google/callback` | | Auto-Verification | Social auth users are automatically `emailVerified: true` | ### Microsoft OAuth Strategy ```javascript passport.use(new MicrosoftStrategy({ clientID: config.microsoft.clientId, clientSecret: config.microsoft.clientSecret, callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', passReqToCallback: true })); ``` | Property | Value | |----------|-------| | Scopes | `https://graph.microsoft.com/user.read openid` (defined in routes when calling `passport.authenticate()`) | | Callback URL | `/api/auth/signin/microsoft/callback` | | Email Source | `profile._json.mail` or `profile._json.userPrincipalName` | ## API Endpoints ### Authentication Routes | Method | Endpoint | Auth | Rate Limited | Description | |--------|----------|------|--------------|-------------| | POST | `/auth/signin/local` | No | Yes (10/15m) | Login with email/password | | GET | `/auth/me` | JWT | No | Get current user info | | PUT | `/auth/password-reset` | No | No | Reset password with token | | PUT | `/auth/password-update` | JWT | No | Change password | | POST | `/auth/send-password-reset-email` | No | Yes (5/1h) | Request reset email | | POST | `/auth/send-email-address-verification-email` | JWT | No | Resend verification | | PUT | `/auth/verify-email` | No | No | Verify email with token | | PUT | `/auth/profile` | JWT | No | Update user profile | | GET | `/auth/email-configured` | No | No | Check email config | | GET | `/auth/signin/google` | No | No | Initiate Google OAuth | | GET | `/auth/signin/google/callback` | No | No | Google OAuth callback | | GET | `/auth/signin/microsoft` | No | No | Initiate Microsoft OAuth | | GET | `/auth/signin/microsoft/callback` | No | No | Microsoft OAuth callback | ### File Endpoints (Public) | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | GET | `/file/download` | No | Download file (backend proxy for local/GCloud) | | POST | `/file/presign` | No | Generate presigned URLs for S3 direct downloads | | POST | `/file/upload/:table/:field` | JWT | Legacy single-file upload | | POST | `/file/upload-sessions/init` | JWT | Initialize chunked upload | | PUT | `/file/upload-sessions/:id/chunks/:idx` | JWT | Upload chunk | | GET | `/file/upload-sessions/:id` | JWT | Check upload session status | | POST | `/file/upload-sessions/:id/finalize` | JWT | Finalize chunked upload | **Note:** The download and presign endpoints are intentionally public to support runtime asset preloading without authentication. Access control for assets should be implemented at the storage level (S3 bucket policies) if needed. ### Endpoint Details #### POST /auth/signin/local **Request:** ```json { "email": "user@example.com", "password": "password123" } ``` **Response (Success 200):** JWT token string **Error Codes (400):** - `auth.userNotFound` - User doesn't exist - `auth.userDisabled` - Account is disabled - `auth.wrongPassword` - Incorrect password - `auth.userNotVerified` - Email not verified #### Self-Registration Self-registration is disabled. The application does not expose `POST /auth/signup` and the frontend does not provide a `/register` page. New users are created by authorized staff through the Users flow and receive an invitation/setup link. #### GET /auth/me **Headers:** `Authorization: Bearer {token}` **Response (Success 200):** ```json { "id": "uuid", "email": "user@example.com", "firstName": "John", "lastName": "Doe", "emailVerified": true, "disabled": false, "provider": "local", "app_role": { "id": "...", "name": "User" }, "custom_permissions": [] } ``` #### PUT /auth/verify-email **Request:** ```json { "token": "email_verification_token_40_chars" } ``` **Response (Success 200):** `true` **Error Codes (400):** - `auth.emailAddressVerificationEmail.invalidToken` - Invalid or expired token #### PUT /auth/password-reset **Request:** ```json { "token": "password_reset_token_40_chars", "password": "newPassword123" } ``` **Response (Success 200):** User object **Error Codes (400):** - `auth.passwordReset.invalidToken` - Invalid or expired token #### PUT /auth/password-update **Headers:** `Authorization: Bearer {token}` **Request:** ```json { "currentPassword": "oldPassword123", "newPassword": "newPassword123" } ``` **Error Codes (400):** - `auth.wrongPassword` - Current password incorrect - `auth.passwordUpdate.samePassword` - New password same as old ## Rate Limiting **File:** `backend/src/middlewares/rateLimiter.js` | Endpoint | Limiter | Max Requests | Window | Key | |----------|---------|--------------|--------|-----| | `/auth/signin/local` | `authLimiter` | 10 | 15 minutes | IP address | | `/auth/send-password-reset-email` | `passwordResetLimiter` | 5 | 1 hour | IP address | **Implementation:** ```javascript // backend/src/routes/auth.ts imports from centralized rate limiter const { authLimiter: signinLimiter, passwordResetLimiter, } = require('../middlewares/rateLimiter'); // Rate limiters are created with createRateLimiter factory const authLimiter = createRateLimiter({ keyPrefix: 'auth', windowMs: 15 * 60 * 1000, // 15 minutes max: 10, message: 'Too many authentication attempts. Please try again later.', skipFailedRequests: false, // Count failed attempts }); ``` - Uses centralized in-memory Map: `rateLimitStore` - Key format: `${keyPrefix}:${req.ip}` - Adds standard rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) - Returns 429 status with JSON response when exceeded - Automatic cleanup of expired entries every 5 minutes - Skips rate limiting in development for localhost ## User Model **File:** `backend/src/db/models/users.js` | Field | Type | Required | Default | Notes | |-------|------|----------|---------|-------| | id | UUID | Yes | UUIDV4 | Primary key | | email | TEXT | Yes | - | Unique, validated as email | | password | TEXT | Yes | - | bcrypt hashed | | firstName | TEXT | No | null | Trimmed on save | | lastName | TEXT | No | null | Trimmed on save | | phoneNumber | TEXT | No | null | | | disabled | BOOLEAN | No | false | Account status | | emailVerified | BOOLEAN | No | false | Verification status | | emailVerificationToken | TEXT | No | null | 40-char hex | | emailVerificationTokenExpiresAt | DATE | No | null | 24h expiry | | passwordResetToken | TEXT | No | null | 40-char hex | | passwordResetTokenExpiresAt | DATE | No | null | 24h expiry | | provider | TEXT | No | 'local' | local/google/microsoft | | app_roleId | UUID | No | null | FK to roles | | importHash | STRING(255) | No | null | Unique, for CSV deduplication | **Note:** The `authenticationUid` field is set dynamically during `createFromAuth()` and `updatePassword()` operations (set to user ID after creation). ### Model Hooks ```javascript users.beforeCreate((users) => { // Trim email, firstName, lastName if (users.provider !== 'local') { users.emailVerified = true; // Auto-verify social auth if (!users.password) { // Generate random password for OAuth users users.password = bcrypt.hashSync(randomPassword, 12); } } }); ``` ## Token Generation **File:** `backend/src/db/api/users.js` ### Email Verification Token ```javascript const token = crypto.randomBytes(20).toString('hex'); // 40-char hex const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours ``` ### Password Reset Token ```javascript const token = crypto.randomBytes(20).toString('hex'); // 40-char hex const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours ``` ### JWT Token **File:** `backend/src/helpers.js` ```javascript static jwtSign(data) { return jwt.sign(data, config.secret_key, { expiresIn: '6h' }); } ``` Payload structure: ```json { "user": { "id": "uuid", "email": "user@example.com" } } ``` ## Frontend Implementation ### Auth Slice (Redux) **File:** `frontend/src/stores/authSlice.ts` ```typescript interface MainState { isFetching: boolean; errorMessage: string; currentUser: any; token: string; notify: any; // Contains showNotification, textNotification, typeNotification } ``` ### Async Thunks **loginUser:** ```typescript export const loginUser = createAsyncThunk( 'auth/loginUser', async (creds: Record, { rejectWithValue }) => { const response = await axios.post('auth/signin/local', creds); return response.data; // JWT token }, ); ``` Expected `creds` object: `{ email: string, password: string }` **findMe:** ```typescript export const findMe = createAsyncThunk('auth/findMe', async () => { const response = await axios.get('auth/me'); return response.data; // Current user }); ``` ### Token Storage On successful login: ```typescript builder.addCase(loginUser.fulfilled, (state, action) => { const token = action.payload; const user = jwt.decode(token); // Store in both storages sessionStorage.setItem('token', token); sessionStorage.setItem('user', JSON.stringify(user)); localStorage.setItem('token', token); localStorage.setItem('user', JSON.stringify(user)); // Set default header axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; }); ``` On logout (reducer action in authSlice): ```typescript logoutUser: (state) => { sessionStorage.removeItem('token'); sessionStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('user'); axios.defaults.headers.common['Authorization'] = ''; // Set to empty in reducer state.currentUser = null; state.token = ''; } ``` ### Axios Interceptors **File:** `frontend/src/pages/_app.tsx` **Request Interceptor (Token Injection):** ```typescript axios.interceptors.request.use((config) => { if (typeof window !== 'undefined') { const token = sessionStorage.getItem('token') || localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } } return config; }); ``` **Response Interceptor (401 Handling + Presigned URL Failure Detection):** ```typescript axios.interceptors.response.use( (response) => response, (error) => { if (typeof window !== 'undefined') { const status = error?.response?.status; const requestUrl = `${error?.config?.url || ''}`; const isLoginRequest = requestUrl.includes('/auth/signin/local') || requestUrl.includes('auth/signin/local'); // Detect presigned S3 URL failures (CORS not configured) // Network errors (status 0) or CORS errors typically indicate S3 CORS issues if ( isPresignedS3Url(requestUrl) && (!status || status === 0 || error.message?.includes('Network Error')) ) { logger.info('[axios] Presigned URL failed, disabling presigned URLs', { url: requestUrl.slice(0, 80), }); disablePresignedUrls(); // Falls back to backend proxy } if (status === 401 && !isLoginRequest) { // Clear stored tokens sessionStorage.removeItem('token'); sessionStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('user'); delete axios.defaults.headers.common['Authorization']; // Redirect to login if not already there if (!window.location.pathname.includes('/login')) { window.location.href = '/login'; } } } return Promise.reject(error); }, ); // Helper function to detect presigned S3 URLs const isPresignedS3Url = (url: string): boolean => { return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); }; ``` **Presigned URL Fallback:** When S3 CORS is not configured, presigned URL requests fail with network errors. The interceptor detects these failures and automatically disables presigned URLs, falling back to the backend proxy for file downloads. ``` ## Authentication Flows ### Local Login Flow ``` 1. User enters email/password on login.tsx 2. dispatch(loginUser({ email, password })) 3. POST /auth/signin/local 4. Backend validates: - User exists - User not disabled - Email verified (if email configured) - Password matches (bcrypt.compare) 5. Return JWT token (6h expiration) 6. Frontend stores token in sessionStorage + localStorage 7. Set axios Authorization header 8. dispatch(findMe()) → GET /auth/me 9. Redirect to /dashboard ``` ### Invitation-Only Account Flow ``` 1. Administrator, Platform Owner, or Account Manager creates a user from Users. 2. Backend creates the user, assigns the selected role, and sends an invitation/setup email when email is configured. 3. User opens the setup link. 4. User sets a password through the password-reset/setup screen. 5. User can log in with email/password. ``` ### Password Reset Flow ``` 1. User enters email on forgot.tsx 2. POST /auth/send-password-reset-email 3. Backend generates passwordResetToken (40-char hex, 24h expiry) 4. Send email with link: /password-reset?token={token} 5. User clicks link → PasswordSetOrReset component 6. User enters new password 7. PUT /auth/password-reset with token + newPassword 8. Backend validates token, hashes new password, updates user 9. Clear token fields 10. Redirect to /login ``` ### OAuth Flow (Google/Microsoft) ``` 1. User clicks "Sign in with Google/Microsoft" 2. GET /auth/signin/google (or microsoft) 3. Passport initiates OAuth flow 4. User grants permissions 5. Provider redirects to callback 6. GET /auth/signin/google/callback?code=... 7. Backend exchanges code for access token 8. Retrieve user profile (email, name) 9. findOrCreate user by email 10. Auto-set emailVerified: true 11. Generate JWT token 12. Redirect to /login?token={jwt} 13. Frontend extracts token from URL, stores, redirects to /dashboard ``` ### Protected Route Access ``` 1. Frontend makes API request 2. Axios interceptor adds: Authorization: Bearer {token} 3. Backend: passport.authenticate('jwt') middleware 4. JWT strategy validates token signature and expiry 5. Decode payload: { user: { id, email } } 6. Query database for user 7. Check user.disabled - reject if true 8. Set req.currentUser = user 9. Proceed to route handler ``` ## Security Features ### Password Security | Feature | Implementation | |---------|----------------| | Algorithm | bcrypt | | Salt Rounds | 12 | | Storage | Hashed only, never plaintext | | Comparison | `bcrypt.compare()` (constant-time) | ### Token Security | Feature | Implementation | |---------|----------------| | JWT Algorithm | HS256 | | Expiration | 6 hours | | Secret | Environment variable `SECRET_KEY` | | Transmission | Bearer token in Authorization header | | Storage | sessionStorage + localStorage | ### Verification Tokens | Token Type | Length | Format | Expiry | |------------|--------|--------|--------| | Email Verification | 40 chars | hex | 24 hours | | Password Reset | 40 chars | hex | 24 hours | ### User Disabling - **Field:** `disabled` boolean on users table - **JWT Validation:** Strategy rejects disabled users - **Auth Check:** Both signin and signup reject disabled users - **Use Case:** Admin can disable accounts without deletion ## Protected Routes Pattern **Backend middleware:** ```javascript const jwtAuth = passport.authenticate('jwt', { session: false }); // Protected routes app.use('/api/users', jwtAuth, usersRoutes); app.use('/api/roles', jwtAuth, rolesRoutes); app.use('/api/permissions', jwtAuth, permissionsRoutes); ``` **Runtime routes with optional auth (environment-based):** The frontend sends `X-Runtime-Environment` header to indicate the environment context: - `production` - Public tour pages (no auth required for GET requests) - `stage` - Preview environment (requires authentication) - `dev` - Constructor editing (requires authentication) ```javascript const requireRuntimeReadOrAuth = (req, res, next) => { const headerEnvironment = req.runtimeContext?.headerEnvironment; const headerProjectSlug = req.runtimeContext?.headerProjectSlug; const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); // Only production is public. Stage requires authentication (workspace for review). const isPublicEnvironment = headerEnvironment === 'production'; if (isPublicEnvironment && isReadOnlyRequest && !isPrivateProductionPresentation(headerProjectSlug)) { req.isRuntimePublicRequest = true; // Allow public read access return next(); } // Private production presentations require JWT + staff permission // or a production_presentation_access grant. return jwtAuth(req, res, next); }; ``` Private production presentation visibility and customer grants are stored in the database separately from broad RBAC permissions. See [private-production-presentations.md](./private-production-presentations.md). **Entities with public read access (production environment only):** - `projects` - Project metadata (filtered fields) - `tour_pages` - Page content including `ui_schema_json` - `project_audio_tracks` - Background audio tracks **File endpoints (no auth required):** ```javascript // No authentication - used for runtime asset loading GET /api/file/download?privateUrl={path} // Backend proxy for local/GCloud storage POST /api/file/presign // Generate presigned URLs for S3 direct download ``` ## Configuration **File:** `backend/src/config.ts` ### Role Configuration ```javascript roles: { admin: 'Administrator', user: 'Analytics Viewer', } ``` Self-registration is disabled, so `config.roles.user` is not used by a public signup endpoint. ### Password Configuration ```javascript bcrypt: { saltRounds: 12 // bcrypt hashing rounds } ``` ### Seed Credentials Seeded user credentials are loaded from backend configuration and environment values. The frontend login page must not display or prefill seeded credentials in any environment. ## Environment Variables **Required:** ```bash # JWT SECRET_KEY=your_secret_key_here # Google OAuth GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret # Microsoft OAuth MS_CLIENT_ID=your_microsoft_client_id MS_CLIENT_SECRET=your_microsoft_client_secret # Email (for verification/reset) EMAIL_USER=your_smtp_username EMAIL_PASS=your_smtp_password ``` **Frontend:** ```bash NEXT_PUBLIC_BACK_API=http://localhost:3000/api ``` ## Error Handling ### Validation Errors (400) ```javascript throw new ValidationError('auth.emailAlreadyInUse'); throw new ValidationError('auth.userNotFound'); throw new ValidationError('auth.wrongPassword'); throw new ValidationError('auth.userNotVerified'); ``` ### Forbidden Errors (403) ```javascript throw new ForbiddenError(); // JWT invalid/missing ``` ### Rate Limit Errors (429) ```javascript res.status(429).send({ message: 'Too many requests. Please try again later.', }); ``` ## File Reference | File | Purpose | |------|---------| | `backend/src/auth/auth.ts` | Passport.js strategy configuration | | `backend/src/routes/auth.ts` | Authentication route handlers | | `backend/src/services/auth.ts` | Authentication business logic | | `backend/src/middlewares/rateLimiter.ts` | Centralized rate limiting middleware | | `backend/src/db/api/users.js` | User database operations | | `backend/src/db/models/users.js` | User Sequelize model | | `backend/src/config.ts` | Auth configuration (bcrypt, secrets) | | `backend/src/helpers.ts` | JWT signing helper | | `backend/src/middlewares/runtime-context.ts` | Runtime environment context (X-Runtime-Environment header) | | `backend/src/middlewares/runtime-public.ts` | Public runtime access filtering and field sanitization | | `frontend/src/stores/authSlice.ts` | Redux auth state | | `frontend/src/pages/login.tsx` | Login page | | `frontend/src/pages/forgot.tsx` | Forgot password page | | `frontend/src/pages/verify-email.tsx` | Email verification page | | `frontend/src/components/PasswordSetOrReset.tsx` | Password reset component | | `frontend/src/pages/_app.tsx` | Axios interceptors, presigned URL failure detection | | `frontend/src/lib/assetUrl.ts` | Presigned URL management and fallback logic | ## Troubleshooting ### Common Issues **"auth.userNotVerified" error:** - Email verification is required when email is configured - Check user's `emailVerified` field in database - Resend verification email via `/auth/send-email-address-verification-email` **401 errors on all requests:** - Token may be expired (6h limit) - Token may be malformed - Check Authorization header format: `Bearer {token}` - Verify SECRET_KEY matches between token generation and validation **Rate limit exceeded (429):** - Wait for window to expire (15 min for signin, 1 hour for signup/reset) - Rate limits are per IP address - Consider adjusting limits in `backend/src/routes/auth.ts` **OAuth callback fails:** - Verify callback URLs match in provider console - Check client ID and secret in environment variables - Ensure OAuth scopes are properly configured **Token not persisting after refresh:** - Check both sessionStorage and localStorage - Verify axios interceptors are properly attached - Check for errors in browser console