This document outlines the implementation plan for adding authentication, authorization, and API key management to the Liturgical Calendar API and Frontend.
Status: ✅ Phase 0 Complete + Phase 2.5 Support + Production Security - JWT authentication with cookie-only auth and production-ready security features
Related Issue: #262 - Implement JWT authentication for PUT/PATCH/DELETE requests
Related Documentation:
- Frontend Authentication Roadmap
- OpenAPI Evaluation Roadmap - Authentication gaps and missing CRUD operations
- Serialization Roadmap - Data serialization coordination
- API Client Libraries Roadmap - Client library endpoint coverage
After reviewing the options below (Supabase, WorkOS, self-hosted OAuth), we've decided to start with a simplified self-hosted JWT implementation for the following reasons:
- Right-sized for current needs - We're protecting a handful of admin endpoints (PUT/PATCH/DELETE for calendar management), not building a multi-tenant SaaS
- Matches issue scope - Issue #262 specifically requests JWT implementation as a focused, achievable goal
- No vendor lock-in - Maintains the project's self-hosting philosophy (Docker support, self-contained API)
- Full control - Complete ownership of authentication flow and data
- Incremental path - Can migrate to Supabase/WorkOS later if the project's needs evolve (RBAC, OAuth, MFA, etc.)
Backend (LiturgicalCalendarAPI):
- Install
firebase/php-jwtlibrary - Create JWT generation endpoint (
/auth/login) - Create JWT validation middleware
- Protect
RegionalDataHandlerPUT/PATCH/DELETE routes - Environment-based secret key management
- Basic user validation (hardcoded or simple file-based initially)
Frontend (LiturgicalCalendarFrontend):
- Simple login UI (modal or page)
- Token storage (sessionStorage/localStorage)
- Add
Authorization: Bearer <token>header to write operations - Basic error handling for 401/403 responses
- User session indicators
See Frontend Authentication Roadmap for detailed frontend implementation plan.
Completed Components:
-
Dependencies
- ✅ Installed
firebase/php-jwtv6.11.1
- ✅ Installed
-
Core Services
- ✅
JwtService(src/Services/JwtService.php) - Token generation, verification, and refresh - ✅
Usermodel (src/Models/Auth/User.php) - Environment-based authentication - ✅
CookieHelper(src/Http/CookieHelper.php) - Secure HttpOnly cookie management
- ✅
-
Middleware
- ✅
JwtAuthMiddleware(src/Http/Middleware/JwtAuthMiddleware.php) - JWT validation for protected routes
- ✅
-
HTTP Handlers
- ✅
LoginHandler(src/Handlers/Auth/LoginHandler.php) - POST/auth/login - ✅
LogoutHandler(src/Handlers/Auth/LogoutHandler.php) - POST/auth/logout - ✅
RefreshHandler(src/Handlers/Auth/RefreshHandler.php) - POST/auth/refresh - ✅
MeHandler(src/Handlers/Auth/MeHandler.php) - GET/auth/me(authentication state check)
- ✅
-
HTTP Exceptions
- ✅
UnauthorizedException(401) - Missing/invalid authentication - ✅
ForbiddenException(403) - Insufficient permissions - ✅ Updated
StatusCodeenum with UNAUTHORIZED and FORBIDDEN cases
- ✅
-
Router Updates
- ✅ Added
/auth/login,/auth/logout,/auth/refresh, and/auth/meroutes - ✅ Applied JWT middleware to
/dataendpoint for PUT/PATCH/DELETE operations - ✅ CORS credentials support (
Access-Control-Allow-Credentials: true)
- ✅ Added
-
Configuration
- ✅ Added JWT environment variables to
.env.example - ✅ Configured development environment in
.env.local
- ✅ Added JWT environment variables to
-
HttpOnly Cookie Authentication (2025-11-27)
- ✅
CookieHelperclass for secure cookie management - ✅
LoginHandlersets HttpOnly cookies after successful authentication - ✅
RefreshHandlerreads refresh token from cookie, sets new access token cookie - ✅
LogoutHandlerclears HttpOnly cookies on logout - ✅
JwtAuthMiddlewarereads token from cookie first, falls back to Authorization header - ✅
MeHandlerfor checking authentication state (essential for cookie-based auth) - ✅ Supports both HttpOnly cookies (preferred) and Authorization header (backwards compatible)
- ✅
Testing Results:
- ✅ Login with valid credentials returns access and refresh tokens
- ✅ Login with invalid credentials returns 401 Unauthorized
- ✅ Token refresh successfully generates new access token
- ✅ DELETE/PATCH/PUT without authentication returns 401 Unauthorized
- ✅ DELETE/PATCH/PUT with valid JWT token passes authentication
- ✅ Invalid/malformed tokens rejected with 401 Unauthorized
- ✅ HttpOnly cookies set correctly on login
- ✅
/auth/mereturns authentication state from cookie - ✅ Logout clears HttpOnly cookies
API Endpoints:
POST /auth/login- Authenticate and receive tokens (sets HttpOnly cookies)POST /auth/logout- End session (clears HttpOnly cookies)POST /auth/refresh- Refresh access token using refresh token (reads/sets cookies)GET /auth/me- Check authentication state (returns user info from token)PUT /data/{category}/{calendar}- Protected (requires JWT)PATCH /data/{category}/{calendar}- Protected (requires JWT)DELETE /data/{category}/{calendar}- Protected (requires JWT)PUT /tests- Protected (requires JWT)PATCH /tests/{test_name}- Protected (requires JWT)DELETE /tests/{test_name}- Protected (requires JWT)
Endpoints Requiring JWT Protection (To Be Implemented):
Per the API Client Libraries Roadmap, the following CRUD endpoints also need JWT protection:
PUT /missals- Create missal (not yet implemented)PATCH /missals/{missal_id}- Update missal (not yet implemented)DELETE /missals/{missal_id}- Delete missal (not yet implemented)PUT /decrees- Create decree (not yet implemented)PATCH /decrees/{decree_id}- Update decree (not yet implemented)DELETE /decrees/{decree_id}- Delete decree (not yet implemented)
See docs/enhancements/OPENAPI_EVALUATION_ROADMAP.md for the full gap analysis.
Development Credentials:
- Username:
admin(configurable viaADMIN_USERNAMEenv var) - Password:
password(change in production viaADMIN_PASSWORD_HASHenv var - Argon2id hash, e.g.,password_hash('your-password', PASSWORD_ARGON2ID))
After Phase 0 proved the concept with self-hosted JWT, the project evaluated identity providers for full RBAC support. The options considered were:
- WorkOS — enterprise-grade but vendor lock-in and cost at scale
- Supabase Auth — open source but less mature PHP SDK
- Zitadel — open source, self-hostable, OIDC-native, built-in RBAC, Login V2 UI with passkeys
Decision: Zitadel was chosen for the following reasons:
- Self-hostable — aligns with the project's Docker-based deployment philosophy
- OIDC-native — standards-based authentication with PKCE support
- Built-in user management — registration, email verification, passkeys, MFA
- Role-based access control — project roles managed via Zitadel Console or Management API
- Login V2 UI — modern, customizable login experience out of the box
- No recurring costs — self-hosted, no per-user fees
- Management API — programmatic user/role/grant management for admin workflows
The legacy JWT authentication (Phase 0) is preserved as a fallback when Zitadel is not configured.
For detailed documentation on the Zitadel implementation, see the project wiki:
- Zitadel RBAC Overview — architecture, roles, protected routes, middleware pipeline, database schema
- Zitadel Implementation Status — completed features and outstanding work with linked GitHub issues
- Zitadel Infrastructure Setup — Docker Compose setup, automated setup script, configuration
- Zitadel Production Deployment — production configuration and deployment guide
- Zitadel Production Security — security hardening for production environments
Identity & Authentication:
- OIDC token validation via JWKS with per-issuer keyset caching (
OidcAuthMiddleware) - Support for both HttpOnly cookie and Authorization header authentication
- OIDC availability checking with 503 fallback (
OidcAvailabilityMiddleware) - HTTPS enforcement for auth endpoints in staging/production
- Legacy JWT authentication preserved as fallback (
JwtAuthMiddleware)
Roles (defined in Zitadel project):
| Role | Purpose |
|---|---|
admin |
System administrator; bypasses all permission checks |
developer |
Register applications and generate API keys |
calendar_editor |
Contribute calendar data (with per-calendar permissions) |
test_editor |
Create and modify test definitions |
Role Request Workflow:
- Users submit requests via
POST /auth/role-requests - Admins approve via
POST /admin/role-requests/{id}/approve - Admins reject via
POST /admin/role-requests/{id}/reject - Approval triggers role grant in Zitadel via Management API
- Role revocation support
Application & API Key Management:
- Developers register applications with approval workflow
- API key generation with scope control (read/write) and rate limiting
- Key rotation and revocation
- API key rate limiting enforcement via
ApiKeyRateLimitMiddleware
Admin Features:
- User management with role revocation
- Application approval workflow
- Notification counts for pending items
- Audit logging of all administrative actions
Infrastructure:
- Docker Compose stack (Zitadel, Login V2, PostgreSQL, Adminer, API, Frontend, Tests)
- Automated
setup-zitadel.shscript for project/role/app creation ZITADEL_INTERNAL_URLsupport for Docker networking- PostgreSQL schema with UUID primary keys and CHECK constraints
Database (PostgreSQL litcal database):
| Table | Purpose |
|---|---|
role_requests |
User role assignment request workflow |
user_calendar_permissions |
Calendar-specific read/write permissions |
permission_requests |
Workflow for requesting calendar access |
applications |
Registered developer applications |
api_keys |
API keys with rate limiting, scope, and expiration |
audit_log |
Security and compliance audit trail |
See the Implementation Status wiki page for the current list of outstanding work with linked GitHub issues.
Zitadel provides coarse-grained role-based access control (RBAC): a user is a calendar_editor or they are not.
The current user_calendar_permissions table adds a layer of per-calendar permissions, but only supports
read/write — it cannot express fine-grained policies like:
- "User X can edit the national calendar of Italy but cannot delete it"
- "User Y can create diocesan calendars under Italy but cannot modify existing ones"
- "User Z can edit any calendar in the Americas wider region"
These relationship-based, resource-level permissions require a dedicated authorization engine.
OpenFGA is the open-source implementation of Google's Zanzibar authorization system. It was evaluated for fine-grained authorization because:
- Relationship-based access control (ReBAC) — permissions are modeled as relationships between users and resources, not just roles
- Open source and self-hostable — aligns with the project's Docker-based philosophy (like Zitadel)
- Language-agnostic — HTTP/gRPC API, usable from PHP without an SDK
- CNCF project — part of the Cloud Native Computing Foundation, with active development
- Designed for scale — handles complex permission graphs efficiently
- Composable with Zitadel — Zitadel handles identity/authentication, OpenFGA handles fine-grained authorization
Browser → API → Zitadel (who is this user? what roles do they have?)
↓
OpenFGA (can this user perform this action on this resource?)
- Zitadel — identity, authentication, coarse-grained roles
- OpenFGA — fine-grained, per-resource permission checks
Each calendar type (wider region, national, diocesan) is an independent resource type with no permission inheritance between them. This reflects the ecclesial reality: national calendars fall under the jurisdiction of bishops' conferences, diocesan calendars under individual dioceses, and wider region calendars may be coordinated across multiple bishops' conferences. Each is a distinct institution with its own jurisdiction.
model
schema 1.1
type user
type wider_region
relations
define admin: [user]
define viewer: [user]
define editor: [user]
define deleter: [user]
type national_calendar
relations
define admin: [user]
define viewer: [user]
define editor: [user]
define deleter: [user]
type diocesan_calendar
relations
define admin: [user]
define viewer: [user]
define editor: [user]
define deleter: [user]
type test_definition
relations
define admin: [user]
define viewer: [user]
define editor: [user]
define deleter: [user]This model enables policies like:
- Grant
adminonnational_calendar:IT→ user can manage permissions for Italy's calendar - Grant
editoronnational_calendar:IT→ user can edit Italy's calendar - Grant
editorondiocesan_calendar:roma_lazio_it→ user can edit Rome's diocesan calendar deleteris separate fromeditor→ edit without delete is possible- Permissions on a wider region do not cascade to national or diocesan calendars; each must be granted independently
The middleware would check OpenFGA before allowing operations:
PUT /data/national/IT → check(user, "editor", "national_calendar:IT")
DELETE /data/national/IT → check(user, "deleter", "national_calendar:IT")
PATCH /data/diocesan/roma_lazio_it → check(user, "editor", "diocesan_calendar:roma_lazio_it")
OpenFGA has been added to the Docker Compose stack:
- openfga container — HTTP API on port 8083, gRPC on port 8084, Playground on port 3001
- openfga-migrate — one-shot container that runs database migrations before OpenFGA starts
- PostgreSQL backend — dedicated
openfgadatabase sharing the existing PostgreSQL instance scripts/setup-openfga.sh— creates the OpenFGA store and loads the authorization model
Setup commands:
docker compose up -d # Start all services (including OpenFGA)
./scripts/setup-openfga.sh --update-env # Create store, load model, update .envConfiguration (added to .env.example):
OPENFGA_API_URL— OpenFGA HTTP API endpoint (default:http://localhost:8083)OPENFGA_STORE_ID— Store ID (output by setup script)OPENFGA_MODEL_ID— Authorization model ID (output by setup script)OPENFGA_HTTP_PORT— Docker host port for HTTP API (default: 8083)OPENFGA_GRPC_PORT— Docker host port for gRPC (default: 8084)OPENFGA_PLAYGROUND_PORT— Docker host port for Playground UI (default: 3001)
Authorization model file: scripts/openfga-model.json
- Phase 1: Add OpenFGA to Docker stack, define authorization model — done
- Phase 2: Create middleware that checks OpenFGA for calendar write operations
- Phase 3: Build admin UI for managing per-resource permissions (replaces
user_calendar_permissionstable) - Phase 4: Migrate existing
user_calendar_permissionsdata to OpenFGA relationship tuples - Phase 5: Remove the
user_calendar_permissionsandpermission_requeststables
The API uses different CORS configurations for public and authenticated endpoints:
The following endpoints are intentionally configured with wildcard CORS:
GET /calendars(MetadataHandler) - Discovery endpoint listing available calendarsGET /calendar(CalendarHandler) - Calendar data retrievalGET /temporale(TemporaleHandler) - Temporale data retrievalGET /events(EventsHandler) - Event catalog- Other read-only GET endpoints
Why wildcard CORS for these endpoints:
- Public data - These endpoints return the same data regardless of who's requesting
- Maximum accessibility - Any website/application can consume this public API
- No authentication needed - No user-specific or protected information
Important: Wildcard CORS (Access-Control-Allow-Origin: *) is incompatible with credentials: 'include'.
Browsers block credential requests to wildcard-CORS endpoints as a security measure.
This is intentional for public endpoints.
The following endpoints require specific CORS origins configured via CORS_ALLOWED_ORIGINS:
POST /auth/login,/auth/logout,/auth/refresh,GET /auth/mePUT /data,PATCH /data,DELETE /data- Other write operations
Why origin-specific CORS for these endpoints:
- Cookie-based authentication - HttpOnly cookies require
credentials: 'include' - Security - Prevents credential leakage to arbitrary origins
- CORS spec requirement - Credentialed requests cannot use wildcard origins
Client-side handling:
// For public endpoints (no credentials needed)
fetch('https://api.example.com/calendars', {
credentials: 'omit' // or simply omit the credentials option
});
// For authenticated endpoints (credentials required)
fetch('https://api.example.com/data', {
method: 'PUT',
credentials: 'include', // Send HttpOnly cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});Configuration:
# .env - Configure allowed origins for credentialed requests
CORS_ALLOWED_ORIGINS=https://frontend.example.com,https://admin.example.com