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

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.

Endpoint Protection

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

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