25 KiB
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
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
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
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:
{
"email": "user@example.com",
"password": "password123"
}
Response (Success 200): JWT token string
Error Codes (400):
auth.userNotFound- User doesn't existauth.userDisabled- Account is disabledauth.wrongPassword- Incorrect passwordauth.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):
{
"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:
{
"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:
{
"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:
{
"currentPassword": "oldPassword123",
"newPassword": "newPassword123"
}
Error Codes (400):
auth.wrongPassword- Current password incorrectauth.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:
// 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 |
| 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
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
const token = crypto.randomBytes(20).toString('hex'); // 40-char hex
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
Password Reset Token
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
static jwtSign(data) {
return jwt.sign(data, config.secret_key, { expiresIn: '6h' });
}
Payload structure:
{
"user": {
"id": "uuid",
"email": "user@example.com"
}
}
Frontend Implementation
Auth Slice (Redux)
File: frontend/src/stores/authSlice.ts
interface MainState {
isFetching: boolean;
errorMessage: string;
currentUser: any;
token: string;
notify: any; // Contains showNotification, textNotification, typeNotification
}
Async Thunks
loginUser:
export const loginUser = createAsyncThunk(
'auth/loginUser',
async (creds: Record<string, string>, { rejectWithValue }) => {
const response = await axios.post('auth/signin/local', creds);
return response.data; // JWT token
},
);
Expected creds object: { email: string, password: string }
findMe:
export const findMe = createAsyncThunk('auth/findMe', async () => {
const response = await axios.get('auth/me');
return response.data; // Current user
});
Token Storage
On successful login:
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):
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):
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):
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
- User enters email/password on login.tsx
- dispatch(loginUser({ email, password }))
- POST /auth/signin/local
- Backend validates:
- User exists
- User not disabled
- Email verified (if email configured)
- Password matches (bcrypt.compare)
- Return JWT token (6h expiration)
- Frontend stores token in sessionStorage + localStorage
- Set axios Authorization header
- dispatch(findMe()) → GET /auth/me
- Redirect to /dashboard
### Invitation-Only Account Flow
- Administrator, Platform Owner, or Account Manager creates a user from Users.
- Backend creates the user, assigns the selected role, and sends an invitation/setup email when email is configured.
- User opens the setup link.
- User sets a password through the password-reset/setup screen.
- User can log in with email/password.
### Password Reset Flow
- User enters email on forgot.tsx
- POST /auth/send-password-reset-email
- Backend generates passwordResetToken (40-char hex, 24h expiry)
- Send email with link: /password-reset?token={token}
- User clicks link → PasswordSetOrReset component
- User enters new password
- PUT /auth/password-reset with token + newPassword
- Backend validates token, hashes new password, updates user
- Clear token fields
- Redirect to /login
### OAuth Flow (Google/Microsoft)
- User clicks "Sign in with Google/Microsoft"
- GET /auth/signin/google (or microsoft)
- Passport initiates OAuth flow
- User grants permissions
- Provider redirects to callback
- GET /auth/signin/google/callback?code=...
- Backend exchanges code for access token
- Retrieve user profile (email, name)
- findOrCreate user by email
- Auto-set emailVerified: true
- Generate JWT token
- Redirect to /login?token={jwt}
- Frontend extracts token from URL, stores, redirects to /dashboard
### Protected Route Access
- Frontend makes API request
- Axios interceptor adds: Authorization: Bearer {token}
- Backend: passport.authenticate('jwt') middleware
- JWT strategy validates token signature and expiry
- Decode payload: { user: { id, email } }
- Query database for user
- Check user.disabled - reject if true
- Set req.currentUser = user
- 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)
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.
Entities with public read access (production environment only):
projects- Project metadata (filtered fields)tour_pages- Page content includingui_schema_jsonproject_audio_tracks- Background audio tracks
File endpoints (no auth required):
// 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
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
}
Self-registration is disabled, so config.roles.user is not used by a public
signup endpoint.
Password Configuration
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:
# 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:
NEXT_PUBLIC_BACK_API=http://localhost:3000/api
Error Handling
Validation Errors (400)
throw new ValidationError('auth.emailAlreadyInUse');
throw new ValidationError('auth.userNotFound');
throw new ValidationError('auth.wrongPassword');
throw new ValidationError('auth.userNotVerified');
Forbidden Errors (403)
throw new ForbiddenError(); // JWT invalid/missing
Rate Limit Errors (429)
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
emailVerifiedfield 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