Authentication Guide

This guide covers JWT-based authentication, role-based access control, and integration with external auth providers. For complete technical details, see .context/api/authentication.md.

Overview

The CMS uses JWT (JSON Web Token) authentication with RS256 signature verification:
  • Token Format: JWT (Bearer token)
  • Algorithm: RS256 (RSA Signature with SHA-256)
  • Provider: Keycloak, Auth0, or custom OAuth2
  • Roles: ROLE_CMS_EDITOR, ROLE_CMS_ADMIN

Quick Start

Development Mode

Generate a development token (DEV ONLY, disabled in production):
curl -X POST https://local.api.cms/dev/auth/token \
  -H "Content-Type: application/json" \
  -d '{
    "role": "cms_admin",
    "sub": "admin@demo.local"
  }'
Response:
{
  "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 3600
}

Using the Token

Include the token in all authenticated requests:
curl https://local.api.cms/admin/articles \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: demo" \
  -H "X-Site-Id: <site-uuid>"

JWT Token Structure

Token Example

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│                                        │                                                │
│          Header (Base64)              │         Payload (Base64)                       │         Signature
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-id-123"
}

Payload (Claims)

{
  "sub": "user-123",
  "email": "user@demo.local",
  "name": "John Doe",
  "roles": ["ROLE_CMS_EDITOR"],
  "tenant_id": 1,
  "iat": 1707476400,
  "exp": 1707480000,
  "iss": "https://auth.example.com",
  "aud": "cms-api"
}
Required Claims:
  • sub or email - User identifier
  • exp - Expiration timestamp
  • iat - Issued at timestamp
Custom Claims:
  • roles - Array of role strings
  • tenant_id - Tenant ID (optional, can be from user table)

Role-Based Access Control (RBAC)

Available Roles

ROLE_CMS_EDITOR

Permissions:
  • ✅ Read all content (articles, pages, media)
  • ✅ Create content
  • ✅ Update own content
  • ✅ Schedule and publish content
  • ✅ Generate preview tokens
  • ❌ Delete content (admin only)
  • ❌ Manage users, menus, blocks
Use Case: Content writers, journalists, bloggers

ROLE_CMS_ADMIN

Permissions:
  • ✅ All ROLE_CMS_EDITOR permissions
  • ✅ Delete content
  • ✅ Archive/unarchive content
  • ✅ Manage users (CRUD, reset passwords)
  • ✅ Manage menus, blocks, settings
  • ✅ Access all admin endpoints
Use Case: Site administrators, content managers

Site-Scoped Roles

Beyond JWT roles, users have site-scoped roles assigned per site within a tenant:
RoleLevelKey Permissions
Editor1Read and create content
Publisher2Editor permissions + publish, schedule, archive content
Site Manager3Publisher permissions + manage content assignments, user roles
Site Owner4Full administrative access within the site (including user management)
Site roles are resolved via the SiteRoleResolver from the UserSiteRole table. ROLE_CMS_ADMIN (from JWT) bypasses all site-role checks.

Role Hierarchy

# config/packages/security.yaml
security:
    role_hierarchy:
        ROLE_CMS_ADMIN: [ROLE_CMS_EDITOR]
ROLE_CMS_ADMIN automatically includes all ROLE_CMS_EDITOR permissions and bypasses all site-scoped and per-resource ACL checks.

Endpoint Protection

#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('SITE_CONTENT_EDIT')"
        ),
        new Delete(
            security: "is_granted('SITE_CONTENT_DELETE')"
        )
    ]
)]
class Article { }

Per-Resource ACL

Beyond role-based access, individual resources (pages, articles, media) can have access control lists for fine-grained permissions.

How It Works

  1. ACL entries are created for specific resources, granting permissions to individual users or site roles
  2. If a resource has ACL entries, only users with matching ACL (or admins/managers) can access it
  3. If a resource has no ACL entries, it remains visible to anyone with site-level permissions (backward-compatible)

Permission Levels

PermissionGrants
viewRead access to the resource
editModify the resource (includes workflow transitions)
deleteDelete the resource
manageFull control (implies view, edit, and delete)

ACL Grant Types

  • User grant (grant_type: "user"): Targets a specific user by UUID
  • Role grant (grant_type: "role"): Targets all users with a specific site role (e.g., publisher)

ACL API Endpoints

Managing ACL entries requires the SITE_CONTENT_ASSIGN permission (site managers and above).
Create an ACL entry:
curl -X POST https://local.api.cms/admin/resource-acls \
  -H "Authorization: Bearer <token>" \
  -H "X-Site-Id: <site-uuid>" \
  -H "Content-Type: application/ld+json" \
  -d '{
    "resource_type": "page",
    "resource_uuid": "<page-uuid>",
    "grant_type": "user",
    "user_uuid": "<user-uuid>",
    "permission": "edit",
    "site": 1
  }'
List ACL entries:
curl https://local.api.cms/admin/resource-acls \
  -H "Authorization: Bearer <token>" \
  -H "X-Site-Id: <site-uuid>"
Delete an ACL entry:
curl -X DELETE https://local.api.cms/admin/resource-acls/<acl-uuid> \
  -H "Authorization: Bearer <token>" \
  -H "X-Site-Id: <site-uuid>"

ACL Bypass Rules

The following users bypass all ACL checks and always have full access:
  • Users with ROLE_CMS_ADMIN (JWT role)
  • Users with site_manager or site_owner site role

Workflow Endpoint ACL

All workflow actions (schedule, publish, archive, assign, etc.) enforce per-resource ACL:
ActionRequired ACL Permission
Schedule, Publish, Archive, Unarchiveedit
Assign, Unassignmanage
View Historyview

Validation Rules

  • grant_type: "user" requires user_uuid to be provided
  • grant_type: "role" requires site_role to be provided
  • Duplicate ACL entries (same resource + grant + permission) return 409 Conflict
  • ACL entries are immutable: to change a permission, delete and recreate

Integration Examples

Keycloak Integration

1. Configure Keycloak:
  • Create realm: cms
  • Create client: cms-api (confidential)
  • Add roles: cms_editor, cms_admin
  • Map roles to token claims
2. Update API Configuration:
# .env
JWKS_URL=https://auth.example.com/realms/cms/protocol/openid-connect/certs
JWT_ISSUER=https://auth.example.com/realms/cms
JWT_AUDIENCE=cms-api
3. Obtain Token:
curl -X POST https://auth.example.com/realms/cms/protocol/openid-connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=cms-api" \
  -d "client_secret=<secret>" \
  -d "username=admin@demo.local" \
  -d "password=secret"
Response:
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "token_type": "Bearer"
}

Auth0 Integration

1. Create Auth0 Application:
  • Type: Machine to Machine
  • Authorized API: CMS API
  • Permissions: read:articles, write:articles, etc.
2. Configure API:
# .env
JWKS_URL=https://<tenant>.auth0.com/.well-known/jwks.json
JWT_ISSUER=https://<tenant>.auth0.com/
JWT_AUDIENCE=https://api.cms.example.com
3. Obtain Token:
curl -X POST https://<tenant>.auth0.com/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "<client-id>",
    "client_secret": "<client-secret>",
    "audience": "https://api.cms.example.com",
    "grant_type": "client_credentials"
  }'

Frontend Integration

Nuxt 3 (Front-Office)

// composables/useAuth.ts
export function useAuth() {
  const token = useCookie('auth_token');
  const user = useState<User>('user');

  const login = async (email: string, password: string) => {
    const response = await fetch('https://auth.example.com/token', {
      method: 'POST',
      body: JSON.stringify({ email, password })
    });
    
    const data = await response.json();
    token.value = data.access_token;
    
    // Decode JWT to get user info
    user.value = parseJwt(data.access_token);
  };

  const logout = () => {
    token.value = null;
    user.value = null;
  };

  return { token, user, login, logout };
}

Vue 3 (Back-Office)

// stores/auth.ts
import { defineStore } from 'pinia';

export const useAuthStore = defineStore('auth', {
  state: () => ({
    token: localStorage.getItem('token'),
    user: null as User | null
  }),

  actions: {
    async login(email: string, password: string) {
      const response = await fetch('https://auth.example.com/token', {
        method: 'POST',
        body: JSON.stringify({ email, password })
      });
      
      const data = await response.json();
      this.token = data.access_token;
      localStorage.setItem('token', this.token);
      
      this.user = parseJwt(this.token);
    },

    logout() {
      this.token = null;
      this.user = null;
      localStorage.removeItem('token');
    }
  },

  getters: {
    isAuthenticated: (state) => !!state.token,
    isAdmin: (state) => state.user?.roles?.includes('ROLE_CMS_ADMIN')
  }
});

API Request Interceptor

// plugins/api.ts
export default defineNuxtPlugin(() => {
  const { token } = useAuth();

  const api = $fetch.create({
    baseURL: process.env.VITE_API_URL || 'https://local.api.cms',
    headers: {
      'X-Tenant-Id': 'demo'
    },
    onRequest({ options }) {
      if (token.value) {
        options.headers = {
          ...options.headers,
          Authorization: `Bearer ${token.value}`
        };
      }
    },
    onResponseError({ response }) {
      if (response.status === 401) {
        // Token expired, redirect to login
        navigateTo('/login');
      }
    }
  });

  return {
    provide: { api }
  };
});

Security Best Practices

Token Storage

✅ DO:
  • Store access token in memory (JavaScript variable)
  • Store refresh token in httpOnly cookie (server-side)
  • Use secure cookies in production (https only)
❌ DON’T:
  • Never store tokens in localStorage (XSS vulnerable)
  • Never store tokens in sessionStorage
  • Never log tokens to console in production
  • Never include tokens in URLs

Token Expiration

Recommended Lifetimes:
  • Access token: 5-15 minutes (short-lived)
  • Refresh token: 30 minutes - 1 hour
  • Dev token: 1 hour (development only)

Automatic Refresh

// Auto-refresh before expiration
const tokenExpiresAt = parseJwt(token.value).exp * 1000;

if (tokenExpiresAt - Date.now() < 120000) { // < 2 minutes
  await refreshToken();
}

Troubleshooting

”Invalid JWT Token”

Causes:
  • Token expired (exp claim in past)
  • Invalid signature (wrong public key)
  • Token format incorrect
Solutions:
  • Check token expiration: jwt.io to decode
  • Verify JWKS URL is correct and accessible
  • Clear JWKS cache: php bin/console cache:clear

”User not found”

Causes:
  • User doesn’t exist in cms_users table
  • Email mismatch between token and database
Solutions:
  • Create user with matching email
  • Check sub or email claim in token matches DB

”Access Denied”

Causes:
  • Missing required role
  • Tenant mismatch (user not in tenant)
Solutions:
  • Check user roles in database
  • Verify X-Tenant-Id header matches user’s tenant_id
  • Check endpoint security requirements

Further Reading

Complete Auth Docs

Detailed authentication documentation

User Management

User CRUD operations

Security Config

Symfony Security component

JWT Specification

JWT RFC 7519