Authentication & Authorization
Overview
Creatiq uses Keycloak as its central identity provider (IdP) for the EduAgentic ecosystem. Authentication flows through Keycloak's OpenID Connect endpoints, with Creatiq performing JIT (Just-In-Time) provisioning of local creatiq_users records on first login.
Source files:
server/middleware/auth.tsserver/routes/auth.tsserver/lib/keycloak.tsserver/middleware/feature-gate.tsserver/middleware/rate-limiter.tsserver/services/subscription/index.tsserver/config/security.tsserver/config/features.ts
Auth Flow
Login (Direct Access Grants)
Creatiq authenticates users via Keycloak's Direct Access Grants (Resource Owner Password Credentials grant type).
Endpoint: POST /api/auth/login
Request:
{
"email": "user@example.com",
"password": "secret"
}
Flow:
- Client sends email + password to
/api/auth/login. - Server calls Keycloak's token endpoint (
/realms/{realm}/protocol/openid-connect/token) withgrant_type=password. - Keycloak validates credentials and returns
access_token,refresh_token, andexpires_in. - Server verifies the issued access token via Keycloak's JWKS endpoint.
- Server extracts the Creatiq role from Keycloak's
realm_access.rolesclaim. - Server JIT-provisions a local
creatiq_usersrecord (or updates existing) with a default plan based on role:adminrole ->premiumplanteacherrole ->proplanstudentrole ->freeplan
- Server returns the tokens and user info to the client.
Response (200):
{
"success": true,
"data": {
"token": "<keycloak_access_token>",
"refreshToken": "<keycloak_refresh_token>",
"expiresIn": 300,
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "User Name",
"plan": "pro"
}
}
}
Token Refresh
Endpoint: POST /api/auth/refresh
Request:
{
"refreshToken": "<keycloak_refresh_token>"
}
Response (200):
{
"success": true,
"data": {
"token": "<new_access_token>",
"refreshToken": "<new_refresh_token>",
"expiresIn": 300
}
}
Get Current User
Endpoint: GET /api/auth/me (requires auth)
Returns the authenticated user's profile, usage stats, and plan details.
Response (200):
{
"success": true,
"data": {
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "User Name",
"plan": "pro"
},
"usage": {
"plan": "pro",
"contentsUsed": 5,
"contentsLimit": 30,
"aiUsed": 12,
"aiLimit": 100,
"storageUsed": 52428800,
"storageLimit": 5368709120
},
"planDetails": { "id": "pro", "name": "Pro", "price": 4, "limits": { "..." : "..." } }
}
}
JWT Payload Structure
Tokens are Keycloak-issued JWTs. The auth middleware maps Keycloak claims to the following internal payload structure:
interface JWTPayload {
/** Keycloak subject (UUID) */
id: string;
documentId?: string;
email: string;
role?: {
id?: number;
name?: string;
type: string; // "admin" | "teacher" | "student"
};
tenantId?: string;
plan?: string; // Enriched from local DB during auth
name?: string;
iat: number;
exp: number;
}
The plan field is not in the Keycloak token itself -- it is enriched from the local creatiq_users table during the auth middleware's JIT provisioning step.
Keycloak Claims
The raw Keycloak access token contains these relevant claims:
| Claim | Description |
|---|---|
sub | Keycloak user UUID |
email | User email address |
name | Full name |
preferred_username | Keycloak username |
realm_access.roles | Array of Keycloak realm roles |
tenant_id | Tenant UUID (custom claim) |
school_id | School UUID (custom claim) |
iat / exp | Issued-at / expiration timestamps |
Token Verification
The auth middleware uses a two-step verification strategy:
-
Keycloak JWKS verification (primary): Verifies the token signature against Keycloak's remote JWKS endpoint (
/realms/{realm}/protocol/openid-connect/certs) using thejoselibrary. Accepts issuers from both internal (Docker) and external Keycloak URLs. -
Local JWT_SECRET fallback: If JWKS verification fails, the middleware attempts verification using the
JWT_SECRETenvironment variable. This path supports locally-issued tokens such as cmi5 launch tokens.
Middleware Chain
1. Auth Middleware (authMiddleware)
Applied to all protected routes. Execution order:
-
Internal API key check: If
x-api-keyheader matchesINTERNAL_API_KEY(timing-safe comparison), the request is authorized as an internal service call. Thex-tenant-codeheader sets the tenant context. -
Token extraction: Reads the JWT from
Authorization: Bearer <token>header or?token=query parameter. -
Token verification: Tries Keycloak JWKS, then falls back to local JWT_SECRET.
-
JIT user provisioning: Calls
subscriptionService.getOrCreateUser()to ensure a localcreatiq_usersrecord exists. Enriches the JWT payload with the user'splanfrom the database. Resets monthly usage counters if the calendar month has changed. -
Request enrichment: Sets
req.jwtPayload,req.user(H5P-compatible user object), andreq.userInfo(extended permissions).
2. Feature Gate Middleware (requireFeature)
Applied selectively to premium endpoints. Checks the user's subscription plan against a feature access map.
// Usage example:
router.post('/generate', requireFeature('pdf-to-h5p'), handler);
Resolves the user's current plan from the database (not from the JWT) to ensure up-to-date plan information.
3. Tenant Resolver Middleware (tenantResolver)
Applied globally. Resolves the tenant from the request hostname:
- Looks up
req.hostnameagainsttenants.domainandtenants.custom_domaincolumns. - If no match, extracts the subdomain from
{slug}.{BASE_DOMAIN}and looks up by slug. - Attaches the resolved
Tenantobject toreq.tenant(or leaves it undefined for the default tenant).
4. Rate Limiter Middleware
Applied per-tier using express-rate-limit. See Rate Limiting below.
H5P Router Auth Middleware
A variant of auth middleware that skips authentication for H5P static file paths (/core, /editor, /libraries, /content, /temp). For static requests, an anonymous user object is attached. AJAX requests go through full auth.
Role Types
Creatiq uses a three-tier role system mapped from Keycloak's realm roles:
| Creatiq Role | Keycloak Realm Roles | Permissions |
|---|---|---|
admin | super_admin, tenant_admin, school_admin | Install/update H5P libraries, install recommended, create restricted content |
teacher | teacher | Install recommended, create restricted content |
student | (any other / default) | Basic content interaction |
The role mapping is performed by extractCreatiqRole() in server/lib/keycloak.ts:
function extractCreatiqRole(claims: KeycloakClaims): 'admin' | 'teacher' | 'student' {
const roles = claims.realm_access?.roles || [];
if (roles.includes('super_admin') || roles.includes('tenant_admin') || roles.includes('school_admin')) {
return 'admin';
}
if (roles.includes('teacher')) {
return 'teacher';
}
return 'student';
}
Extended User Info
The role determines H5P-specific permissions:
| Permission | admin | teacher | student |
|---|---|---|---|
canInstallRecommended | yes | yes | no |
canUpdateAndInstallLibraries | yes | no | no |
canCreateRestricted | yes | yes | no |
Stripe Checkout & Billing Portal
Subscription Plans
| Plan | Price | Contents/Month | AI Generations/Month | Storage |
|---|---|---|---|---|
| Free | $0 | 3 | 5 | 100 MB |
| Pro | $4/mo | 30 | 100 | 5 GB |
| Premium | $8/mo | Unlimited | Unlimited | 50 GB |
Endpoint: GET /api/auth/plans -- returns all available plans (no auth required).
Create Checkout Session
Endpoint: POST /api/auth/checkout (requires auth)
Request:
{
"planId": "pro" // "pro" or "premium"
}
Flow:
- Looks up the authenticated user in the local database.
- Creates a Stripe customer if one does not exist (stores
stripe_customer_id). - Creates a Stripe Checkout Session in
subscriptionmode with the plan'sstripePriceId. - Returns the checkout URL for client-side redirect.
Response (200):
{
"success": true,
"data": { "url": "https://checkout.stripe.com/..." }
}
Create Billing Portal Session
Endpoint: POST /api/auth/billing-portal (requires auth)
Creates a Stripe Customer Portal session so the user can manage their subscription (cancel, update payment method, view invoices).
Response (200):
{
"success": true,
"data": { "url": "https://billing.stripe.com/..." }
}
Stripe Webhook Handling
Endpoint: POST /api/auth/webhook
Receives Stripe webhook events. The handler:
- Verifies the webhook signature using
STRIPE_WEBHOOK_SECRET. - Deduplicates events using Redis (
webhook:{event_id}key with 24-hour TTL). - Processes the event inside a database transaction.
Handled Event Types
| Event | Action |
|---|---|
checkout.session.completed | Updates user's plan and stripe_subscription_id based on metadata.userId and metadata.planId |
customer.subscription.deleted | Downgrades user to free plan, clears stripe_subscription_id |
invoice.payment_failed | Logs a warning (no plan change) |
Rate Limiting
Rate limits are enforced per IP using express-rate-limit. All tiers use a 15-minute sliding window with standardHeaders: true (RateLimit-* headers) and legacyHeaders: false.
| Tier | Max Requests / 15 min | Applied To |
|---|---|---|
| AUTH | 10 | /api/auth/login, /api/auth/refresh |
| AI | 50 | AI generation endpoints |
| API | 100 | General authenticated API endpoints |
| PUBLIC | 200 | Public/unauthenticated endpoints |
When a rate limit is exceeded, the response is:
{
"success": false,
"error": "Too many authentication attempts. Please try again later."
}
In E2E test environments (
E2E_TEST_CODEis set), all limits are raised to 10,000 to prevent test flakiness.
Error Responses
401 Unauthorized
Returned when authentication is missing or invalid.
No token provided:
{
"success": false,
"error": "Authentication required"
}
Invalid or expired token:
{
"success": false,
"error": "Invalid or expired token"
}
Invalid credentials (login):
{
"success": false,
"error": "Invalid email or password"
}
Disabled account (login):
{
"success": false,
"error": "Account is disabled"
}
Token refresh failed:
{
"success": false,
"error": "Token refresh failed"
}
403 Forbidden
Returned by the feature gate middleware when the user's plan does not include the requested feature.
{
"success": false,
"error": "feature_not_available",
"requiredPlan": "pro",
"message": "This feature requires the pro plan or higher"
}
Feature Access by Plan
| Feature Flag | Free | Pro | Premium |
|---|---|---|---|
pdf-to-h5p | - | yes | yes |
video-to-h5p | - | - | yes |
image-hotspot | - | yes | yes |
url-to-h5p | - | yes | yes |
blooms-critique | - | yes | yes |
differentiation | - | yes | yes |
bulk-generation | - | yes | yes |
lesson-bundle | - | - | yes |
branching-scenario | - | - | yes |
analytics-dashboard | - | yes | yes |
ai-recommendation | - | - | yes |
content-remixer | - | yes | yes |
lti-integration | - | - | yes |
lti-ags | - | - | yes |
lti-nrps | - | - | yes |
cmi5-launch | - | yes | yes |
whitelabel | - | - | yes |
scorm-export | - | yes | yes |
Environment Variables
| Variable | Description | Default |
|---|---|---|
KEYCLOAK_BASE_URL | Internal Keycloak base URL | http://eduagentic-pass:8080 |
KEYCLOAK_EXTERNAL_URL | External (browser-facing) Keycloak URL | http://localhost:22020 |
KEYCLOAK_REALM | Keycloak realm name | eduagentic |
KEYCLOAK_CLIENT_ID | Keycloak client ID | creatiq |
KEYCLOAK_CLIENT_SECRET | Keycloak client secret | (required) |
JWT_SECRET | Secret for locally-issued tokens (cmi5 launch) | (optional) |
INTERNAL_API_KEY | API key for service-to-service calls | (optional) |
STRIPE_SECRET_KEY | Stripe API secret key | (optional, disables payments if unset) |
STRIPE_WEBHOOK_SECRET | Stripe webhook signing secret | (required for webhooks) |
STRIPE_PRO_PRICE_ID | Stripe Price ID for the Pro plan | (required for checkout) |
STRIPE_PREMIUM_PRICE_ID | Stripe Price ID for the Premium plan | (required for checkout) |
NEXT_PUBLIC_APP_URL | Application URL for Stripe redirect URLs | http://localhost:17000 |