_private/qwestly-hire-docs/features/authentication-and-signup.md

Authentication and Signup Flow

This document describes the authentication and signup flow for Qwestly Hire, including user signup, authorization checks, and access control.

Overview

Qwestly Hire uses Auth0 for authentication with a custom authorization system that:

  • Requires users to sign up before accessing the application
  • Syncs user data from Auth0 to our MongoDB database
  • Checks authorization status on every login
  • Blocks unauthorized users from accessing protected routes
  • Supports both password and Google social signup
  • Routes hiring managers through hire onboarding (/onboarding/*) until onboarding_completed_at is set; see hire-onboarding.md

Architecture

Components

  1. Auth0 - Handles user authentication (login, logout, session management)
  2. User Model - MongoDB collection storing user data and permissions
  3. Signup Service - Manages incomplete signups and user syncing
  4. Authorization Endpoint - Checks user access and redeems signup tokens
  5. Middleware - Protects routes and enforces authorization
  6. Site Token - JWT token stored in session containing authorization status
  7. App base URL - Canonical origin for Auth0 (appBaseUrl) and server-side calls to /api/auth/authorized. Resolved at startup from APP_BASE_URL, or VERCEL_BRANCH_URL on Vercel preview deployments when APP_BASE_URL is unset (see src/config/server.ts and src/lib/normalize-app-base-url.ts).

User Model

Users are stored in MongoDB with the following structure:

{
  user_id: string;              // Auth0 user ID (unique)
  name: string;                 // Cached from Auth0
  email: string;                // Cached from Auth0
  work_email_verified: boolean; // Email verification status
  company_ids: string[];        // Array of company IDs user has access to
  parent_org_ids: string[];     // Array of parent org IDs user has access to
  desired_roles: string[];      // Array of desired roles (optional)
  onboarding_completed_at?: Date | null; // Set when hire onboarding finishes (see hire-onboarding.md)
  onboarding_progress?: {      // Wizard position for /onboarding (optional)
    current_step: 'company' | 'roles' | 'job' | 'hm-notes' | 'review';
    completed_steps?: string[];
    draft_job_id?: string;
  };
  created_at: Date;
  updated_at: Date;
}

Permission Model

  • Users can be assigned to multiple companies and/or parent organizations
  • If a user has parent_org_ids but no company_ids, they get access to all companies within those parent orgs
  • Users must have at least one company or parent org assigned (enforced at application level, not database level)
  • During signup, users are created with empty arrays; admins must assign companies/parent orgs later

Signup Flow

Password Signup

  1. User visits /signup page
  2. User fills out form with name, email, and password
  3. Form is submitted to POST /api/auth/signup/password
  4. API route:
    • Validates input (email format, password strength)
    • Checks if user already exists
    • Creates user in Auth0 via Management API
    • Syncs user data to MongoDB (syncUserFromAuth0) without setting onboarding_completed_at
    • Returns success with redirectTo: /auth/login?returnTo=/ (or encoded /) so post-login routing can send the user into hire onboarding as needed
  5. User logs in; / and /dashboard redirect into /onboarding/* until hire onboarding is finished (see hire-onboarding.md)

Google Social Signup

  1. User visits /signup page
  2. User optionally provides email/name before clicking "Sign in with Google"
  3. If email provided:
    • POST /api/auth/signup/initiate-social is called
    • Creates incomplete signup record with signup_token
    • Token is stored in:
      • localStorage (key: signup_token)
      • Cookie (name: signup_token, expires in 24 hours)
  4. User is redirected to Auth0 Google login (return URL may still use legacy /signup/complete, which redirects to /onboarding/company)
  5. After Google authentication, Auth0 may land on /signup/complete; that route immediately redirects to /onboarding/company
  6. beforeSessionSaved hook runs:
    • Checks for signup_token cookie
    • If found, calls POST /api/auth/authorized with token
    • Endpoint redeems token, syncs user data, and checks authorization
    • Cookie is deleted
  7. User continues in hire onboarding at /onboarding/* (company, roles, first job, optional HM notes, review). Legacy /signup/complete is only a redirect to /onboarding/company.
  8. Optional: social completion endpoints (POST /api/auth/signup/complete-social, POST /api/auth/signup/mark-completed) may persist company_ids / desired_roles when provided; identity-only paths no longer imply โ€œsignup completeโ€ for app access.
  9. siteToken is generated with authorization and hire_onboarding_complete (derived from onboarding_completed_at) and stored in session

Hire onboarding completion (app access)

Full access to the main app (e.g. /dashboard) requires onboarding_completed_at to be set on the user document. That happens at the end of the hire onboarding wizard (see hire-onboarding.md), not via has_completed_signup (removed from the hire path).

Flow (summary):

  1. User authenticates via Auth0 (password or social)
  2. User is synced in MongoDB and may have empty company_ids until they pick a company in /onboarding/company
  3. Wizard persists step state via POST /api/onboarding/progress and related onboarding APIs
  4. When the user publishes their first job from onboarding, onboarding_completed_at is set
  5. hire_onboarding_complete in the authorized response / site token becomes true; dashboard stops redirecting into the wizard

Notes:

  • @qwestly.com primary emails are authorized as admins but still follow the same onboarding_completed_at gate for โ€œhire onboarding completeโ€ unless product changes that rule
  • The proxy (src/proxy.ts) refreshes authorized / hire_onboarding_complete on the site token but does not redirect incomplete users into /onboarding (the dashboard server page does)

Account Request Flow (Cross-App Users)

A unique scenario occurs when a user has an Auth0 account (e.g., from the candidate app) but doesn't have a database record in the hire app. This prevents infinite redirect loops and provides a clear path for account creation.

Problem:

  • User authenticates via Auth0 (valid session exists)
  • User tries to access dashboard (/) or /dashboard
  • Dashboard checks for dbUser record โ†’ not found
  • Without an intermediary step, naive redirects between โ€œfinish signupโ€ and โ€œhomeโ€ can loop

Solution: The /account-request page acts as an intermediary step that:

  1. Confirms the user wants to create an account in this application
  2. Prevents automatic redirects that could cause loops
  3. Provides clear messaging about what account creation entails

Flow:

  1. User with Auth0 account (but no dbUser record) tries to access dashboard
  2. Dashboard checks for dbUser โ†’ not found
  3. User is redirected to /account-request
  4. /account-request page:
    • Shows user's logged-in email address
    • Explains they don't have an account in this application yet
    • Asks if they want to create an account
    • Provides two options:
      • Cancel: Logs out and returns to signup page
      • Create Account: Navigates to /onboarding/company to start hire onboarding
  5. After dbUser exists, /dashboard still redirects into /onboarding/* until onboarding_completed_at is set

Key Differences from Normal Signup:

  • Normal signup: User starts at /signup โ†’ creates Auth0 account โ†’ lands in hire onboarding when appropriate
  • Account request: User already has Auth0 account โ†’ confirms account creation โ†’ /onboarding/company
  • Legacy /signup/complete only redirects to /onboarding/company

Middleware Protection:

  • /account-request is excluded from the โ€œunauthorizedโ€ redirect (similar to /signup routes)
  • Allows authenticated users without a hire dbUser row to reach account request / onboarding

Implementation Details:

  • Dashboard (src/app/dashboard/page.tsx): Redirects to /account-request when dbUser doesn't exist; redirects into /onboarding/* when hire onboarding is incomplete
  • Account Request Page (src/app/account-request/page.tsx / client.tsx): Create Account โ†’ /onboarding/company
  • Signup Complete (src/app/signup/complete/page.tsx): redirect to /onboarding/company
  • Proxy (src/proxy.ts): Does not redirect incomplete hire onboarding to /onboarding (dashboard owns that); still refreshes hire_onboarding_complete on the site token when needed

Signup Token Flow

The signup_token mechanism allows us to:

  • Link the Auth0 authentication to our signup process
  • Sync user data from Auth0 to our database
  • Handle the case where user clicks Google signup before providing email

Token Storage:

  • Created when user initiates social signup (if email provided)
  • Stored in cookie and localStorage for cross-browser detection
  • Expires after 24 hours
  • Redeemed in beforeSessionSaved hook after Auth0 login

Token Lookup:

  • When completing social signup, the system tries to find incomplete signup by:
    1. Token (if provided)
    2. User ID (from Auth0 session)
    3. Email (from Auth0 session)
  • This handles cases where token is lost but user is authenticated

Authorization System

Authorization Check

Authorization is checked in two scenarios:

  1. During Signup (with signup_token):

    • POST /api/auth/authorized is called with signup_token
    • Token is redeemed (incomplete signup is marked complete)
    • User data is synced to database
    • Authorization is checked
  2. On Every Login (without signup_token):

    • GET /api/auth/authorized is called with Bearer token
    • Authorization is checked based on user_id

Authorization Rules

A user is authorized if:

  1. Email Whitelist: User's email ends with @qwestly.com (automatic authorization)
  2. Database Check: User exists in the users collection (user_id matches)

Site Token

The siteToken is a JWT token stored in session.user.siteToken containing:

{
  userId: string;              // Auth0 user ID
  userRole?: string;           // "admin" if in ADMIN_EMAILS env var or @qwestly.com email
  authorized?: boolean;        // Whether user has access to the app
  hire_onboarding_complete?: boolean; // True when `onboarding_completed_at` is set on the user
  onboarding_completed_at?: string | null; // ISO timestamp from DB (optional on token)
}

Token Generation:

  • Generated in beforeSessionSaved hook after checking authorization
  • Uses jose library for signing (HS256 algorithm)
  • Secret: JWT_SITE_TOKEN_SECRET (fallback: JWT_SECRET, AUTH0_SECRET)
  • Stored in session for subsequent requests

Token Usage:

  • Proxy / middleware decode token to check authorized and refresh hire_onboarding_complete via checkAndUpdateAuthorization when needed
  • Can be decoded in API routes/pages to check user role and authorization
  • Prevents unauthorized users from accessing protected routes

Middleware Protection

The proxy (src/proxy.ts) protects all routes except:

  • /auth/* - Auth0 routes (login, logout, callback)
  • /signup/* - Signup pages
  • /account-request - Account request page (for cross-app users)
  • /unauthorized - Unauthorized access page
  • /api/auth/signup/* - Signup API routes
  • /api/auth/authorized - Authorization check endpoint

The proxy uses a config matcher to exclude static files, images, and metadata files from processing.

Middleware Flow

  1. Check if route is public โ†’ allow through
  2. Get Auth0 session
  3. If no session โ†’ redirect to /auth/login
  4. Decode siteToken from session for authorized / hire_onboarding_complete
  5. If token is missing or incomplete, proxy may call checkAndUpdateAuthorization() to refresh authorized and hire_onboarding_complete (see src/services/auth0.ts)
  6. Check authorized property:
    • If false โ†’ redirect to /unauthorized (unless path is exempt, e.g. /account-request)
    • If true โ†’ allow the request through the proxy
  7. Hire onboarding gating for the main app is enforced on /dashboard (server redirect), not via a proxy redirect to /onboarding
  8. For /admin routes: additional check for @qwestly.com email

Note: On first request after login, siteToken may not exist yet (it's generated in beforeSessionSaved). In this case, the proxy checks authorization via API as a fallback and updates the session if needed.

Fallback Authorization Check:

  • If siteToken is missing or shows unauthorized, proxy calls checkAndUpdateAuthorization() API
  • This fetches latest authorization status and regenerates siteToken if needed
  • Prevents issues where DB-backed flags changed but session hasn't refreshed yet

Unauthorized Access

When a user is authenticated but not authorized (doesn't have access to the application), they are redirected to /unauthorized. This page:

  • Shows a clear "Access Unauthorized" message
  • Displays the user's email address
  • Provides options to logout or sign up
  • Explains that they should contact an administrator if they believe they should have access

This is different from the signup page, which is for users who haven't created an account yet. The unauthorized page is for users who have authenticated but don't have access permissions.

API Endpoints

/api/auth/signup/password

Creates a password-based account.

Request:

{
  "name": "John Doe",
  "email": "john@example.com",
  "password": "securepassword123"
}

Response:

{
  "success": true,
  "message": "Account created successfully. Please log in to complete your signup.",
  "redirectTo": "/auth/login?returnTo=%2F"
}

/api/auth/signup/initiate-social

Initiates social signup by creating an incomplete signup record.

Request:

{
  "email": "john@example.com",
  "name": "John Doe" // optional
}

Response:

{
  "success": true,
  "token": "abc123..." // signup_token
}

/api/auth/signup/complete-social

Completes social signup after authentication. Requires user to be authenticated.

Request:

{
  "token": "abc123...",        // signup_token (optional, can find by userId/email)
  "company_ids": ["company1"], // Optional: assign now, or leave empty and use /onboarding
  "desired_roles": ["role1"]   // Optional: array of desired roles
}

Response:

{
  "success": true,
  "message": "Signup completed successfully",
  "redirectTo": "/"
}

Behavior:

  • Finds incomplete signup by token, userId, or email (in that order)
  • If no incomplete signup found but user exists, returns success
  • If no incomplete signup and user doesn't exist, creates incomplete signup or syncs user directly
  • When company_ids are provided, persists them (and optional desired_roles); hire onboarding completion is still driven by onboarding_completed_at, not this endpoint alone
  • Syncs user data from Auth0 to database

/api/auth/signup/mark-completed

Updates the user record when a client submits company/role selection, or performs an identity-only sync when company_ids is empty.

Request:

{
  "company_ids": ["company1"], // Optional: empty โ†’ identity sync + redirect home (hire onboarding still applies)
  "desired_roles": ["role1"]    // Optional: array of desired roles
}

Response (with company_ids):

{
  "success": true,
  "message": "Signup marked as completed"
}

Response (identity-only, empty company_ids):

{
  "success": true,
  "message": "Identity synced",
  "redirectTo": "/"
}

Behavior:

  • Requires user to be authenticated
  • Creates user record if it doesn't exist (when needed)
  • With non-empty company_ids: assigns company_ids / optional desired_roles (does not set onboarding_completed_at)
  • With empty company_ids: may sync identity only and return redirectTo: /; user still completes hire onboarding in /onboarding/*

/api/auth/signup/check-incomplete

Checks if current authenticated user has an incomplete signup.

Response:

{
  "hasIncompleteSignup": true
}

/api/auth/authorized

Checks user authorization and redeems signup tokens.

GET - Check authorization via Bearer token:

Authorization: Bearer <authCheckToken>

POST - Redeem signup token and check authorization:

{
  "signup_token": "abc123...", // Optional: if provided, redeems token and syncs user
  "userId": "auth0|123",        // Required
  "email": "john@example.com"   // Required
}

Response (POST example):

{
  "authorized": true,
  "hire_onboarding_complete": false,
  "onboarding_completed_at": null,
  "success": true,
  "admin": false
}

Behavior:

  • If signup_token provided: redeems token, completes incomplete signup, syncs user
  • If no signup_token: checks if user exists, syncs from Auth0 if missing
  • Returns authorized, hire_onboarding_complete (from onboarding_completed_at), onboarding_completed_at, and optional admin

User Data Syncing

User data is synced from Auth0 to MongoDB in the following scenarios:

  1. Password Signup: Immediately after Auth0 user creation (no onboarding_completed_at until hire onboarding finishes)
  2. Social Signup: When signup_token is redeemed in beforeSessionSaved via /api/auth/authorized
  3. Complete Social Signup: When /api/auth/signup/complete-social is called (may set company_ids / desired_roles when provided)
  4. Mark Completed: When /api/auth/signup/mark-completed is called (company assignment and/or identity-only sync)
  5. Manual Sync: Can be called via syncUserFromAuth0() function

The sync process:

  • Checks if user exists in database
  • If exists: Updates name, email, and work_email_verified
  • If not exists: Creates new user record with empty company_ids and parent_org_ids until onboarding assigns them

Note: User sync requires Node.js runtime and cannot run in Edge Runtime. This is why it's handled via API endpoints rather than directly in beforeSessionSaved.

Environment Variables

Required environment variables:

# Public origin of this app (must match the URL users use; required for Auth0 callbacks and internal fetches).
# Local dev (this repo uses port 3001):
APP_BASE_URL=http://localhost:3001
# On Vercel preview deployments, VERCEL_BRANCH_URL is injected automatically and is used when APP_BASE_URL is not set.

# Auth0 Configuration
AUTH0_DOMAIN=auth.qwestly.com
AUTH0_API_CLIENT_ID=your_client_id
AUTH0_API_CLIENT_SECRET=your_client_secret
AUTH0_SECRET=your_auth0_secret

# Site token JWT secret (standardized across apps)
JWT_SITE_TOKEN_SECRET=your_jwt_site_token_secret

# Admin Emails (comma-separated)
ADMIN_EMAILS=admin1@qwestly.com,admin2@qwestly.com

# Vercel Protection Bypass (for internal API calls)
VERCEL_AUTOMATION_BYPASS_SECRET=your_bypass_secret

# MongoDB
MONGODB_URI=your_mongodb_connection_string

If neither APP_BASE_URL nor VERCEL_BRANCH_URL is set, the app fails at startup when server config loads (Auth0 and any code importing appBaseUrl from @/config/server).

Security Considerations

  1. Signup Tokens: Expire after 24 hours, single-use (marked as completed after redemption)
  2. Site Tokens: Signed with HS256, contain authorization status to prevent unauthorized access
  3. Email Whitelist: @qwestly.com primary emails are treated as authorized/admin per isPrimaryQwestlyEmail; hire onboarding completion still follows onboarding_completed_at unless product policy changes
  4. Middleware Protection: All routes protected by default except public routes
  5. Edge Runtime: Authorization checks use fetch requests (Edge-compatible) rather than direct database access

Troubleshooting

User Can't Access App After Signup

  1. Check if user exists in users collection
  2. Verify siteToken is present in session (check session.user.siteToken)
  3. Decode siteToken to check authorized and hire_onboarding_complete
  4. Check User.onboarding_completed_at in MongoDB (source of truth for hire onboarding)
  5. Verify user has at least one company or parent org if they should be past /onboarding/company
  6. Check dashboard / onboarding redirect logs (resolveOnboardingRedirectPath) vs proxy logs for authorized

Signup Token Not Redeemed

  1. Check if signup_token cookie exists before login
  2. Verify cookie is being read in beforeSessionSaved
  3. Check /api/auth/authorized endpoint logs for errors
  4. Ensure VERCEL_AUTOMATION_BYPASS_SECRET is set if using Vercel

431 Error (Request Header Fields Too Large)

  1. Clear browser cookies
  2. Check for large Auth0 session cookies
  3. Verify proxy isn't adding unnecessary headers
  4. Check if siteToken is getting too large
  • docs/features/hire-onboarding.md - Hire onboarding wizard, APIs, and completion gate
  • src/config/server.ts - Canonical appBaseUrl from APP_BASE_URL or VERCEL_BRANCH_URL
  • src/lib/normalize-app-base-url.ts - Normalizes env values to https:// / http:// origins
  • src/lib/hire-onboarding/state.ts - hasCompletedHireOnboarding, resolveOnboardingRedirectPath, step constants
  • src/services/auth0.ts - Shared Auth0Client instance, beforeSessionSaved, and authorization refresh helpers
  • src/services/signup.service.ts - Signup token management and user syncing
  • src/services/token.ts - JWT token encoding/decoding
  • src/models/User.ts - User Mongoose model
  • src/proxy.ts - Route protection and authorization checks
  • src/app/api/auth/authorized/route.ts - Authorization endpoint
  • src/app/api/auth/signup/[[...slug]]/route.ts - Signup API routes (password, initiate-social, complete-social, mark-completed, check-incomplete)
  • src/app/api/onboarding/[[...slug]]/route.ts - Hire onboarding APIs (progress, company, publish)
  • src/app/signup/page.tsx - Signup page
  • src/app/signup/complete/page.tsx - Legacy URL; redirects to /onboarding/company
  • src/app/onboarding/* - Hire onboarding UI
  • src/app/account-request/page.tsx - Account request page (for users with Auth0 accounts but no dbUser record)
  • src/app/account-request/client.tsx - Account request client component
  • src/app/unauthorized/page.tsx - Unauthorized access page