Skip to main content

Authentication & roles

Authentication is delegated entirely to Auth0 via the @auth0/nextjs-auth0 SDK. Authorisation is role-based, derived from a namespaced JWT claim, with a global role catalogue plus per-module capability helpers.

Auth0 integration​

packages/core/lib/auth/auth0.js constructs a single Auth0Client. It reads the standard Auth0 env vars automatically (AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET, APP_BASE_URL) and requests the audience from AUTH0_AUDIENCE (the API Identifier, shared with the backend) so the access token is a full RS256 JWT the backend (and this app) can decode:

authorizationParameters: {
audience: process.env.AUTH0_AUDIENCE,
scope: 'openid profile email',
}
note

AUTH0_AUDIENCE is the same value the backend validates. It is not a secret (it already travels inside every token), but it is a per-tenant setting, so it lives in the environment rather than being hard-coded.

The proxy delegates all /auth/* routes to auth0.middleware(): /auth/login, /auth/logout, /auth/callback, /auth/profile, /auth/access-token.

onCallback flow​

Runs once after the code-for-tokens exchange:

The language for these redirects is extracted from the returnTo path; the backend sign-up POST happens later from the registration form, not here.

Session shape​

Helpers receive the full session returned by auth0.getSession() and read:

FieldMeaning
session.user.subAuth0 unique user id
session.tokenSet.accessTokenRS256 JWT (Bearer token + role claim source)
session.user[ROLES_CLAIM]roles, when the Auth0 Action targets the ID token

Roles​

packages/core/lib/auth/roles.js is the single source of truth. Roles come from a namespaced custom claim. Auth0 uses the API audience as the namespace, so the claim is derived from AUTH0_AUDIENCE (read server-side, where the token is decoded):

export const ROLES_CLAIM = `${process.env.AUTH0_AUDIENCE}/roles`;

getUserRoles(session) decodes the access-token payload (base64url, no re-verification — Auth0 already verified the signature at callback) and reads the claim array.

Role catalogue​

ROLES keyValue
PLATFORM_ADMINMODEL-CITY-PLATFORM-ADMIN
OPERATORMODEL-CITY-OPERATOR
BACKOFFICEMODEL-CITY-BACKOFFICE
CITIZENMODEL-CITY-CITIZEN
MOBILITY_AGENTMODEL-CITY-MOBILITY-AGENT

Primitives vs capability helpers​

  • Core primitives (roles.js): getUserRoles, hasRole, hasAnyRole, plus cross-cutting gates — isCitizen, isStaffUser, canAccessAdministration, canManageUsers, canAccessBackoffice, canAccessOperatorPanel, canViewGeneralSections, canViewBookings.
  • Per-module capability helpers build on the primitives:
ModuleFileHelpers
engagementengagement/lib/auth/roles.jscanViewParticipation, canManageParticipation, canCreateQuestion, canManageSecurityAlerts
leisureleisure/lib/auth/roles.jscanManageTourism, canManagePublicSpaces, canManageSpaceResources, canBookReservation, canManageEvents, canDeleteEvents, canManageEventTickets, canBuyEventTickets
mobilitymobility/lib/auth/roles.jscanViewMobilityOps, canIssueSanctions

These helpers gate both Server Actions (a write returns { error: 'forbidden' } when the check fails) and UI affordances.

Two server-side gates​

  1. Session + registration gate — (app)/layout.js (core/routes-app/layout.js) wraps every authenticated page:

    • no session → redirect('/{lang}/login');
    • session but expired token → render AppShell with the session-expired modal;
    • session but no backend profile (HEAD checkUserExists is not ok) → redirect('/{lang}/register') — a valid Auth0 session alone is not enough;
    • profile confirmed → render AppShell.
  2. Capability-gated navigation — buildNavSections() (core/lib/nav/sections.js) assembles the nav model from the capability helpers (canViewGeneralSections, canViewBookings, canManageSecurityAlerts, canViewMobilityOps, canAccessAdministration, isStaffUser), so each role sees a different set of sections and sub-items. It then drops any section whose module is disabled (see Modularity).