39948-vm/documentation/authentication-system.md
2026-07-03 16:11:24 +02:00

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 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):

{
  "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 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:

// 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

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

  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)
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 including ui_schema_json
  • project_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 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