Skip to main content
DocsReferenceAuthentication
Back to docs

Authentication

Keycloak OIDC, JWT tokens, and Stripe integration

reference
authentication

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.ts
  • server/routes/auth.ts
  • server/lib/keycloak.ts
  • server/middleware/feature-gate.ts
  • server/middleware/rate-limiter.ts
  • server/services/subscription/index.ts
  • server/config/security.ts
  • server/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:

  1. Client sends email + password to /api/auth/login.
  2. Server calls Keycloak's token endpoint (/realms/{realm}/protocol/openid-connect/token) with grant_type=password.
  3. Keycloak validates credentials and returns access_token, refresh_token, and expires_in.
  4. Server verifies the issued access token via Keycloak's JWKS endpoint.
  5. Server extracts the Creatiq role from Keycloak's realm_access.roles claim.
  6. Server JIT-provisions a local creatiq_users record (or updates existing) with a default plan based on role:
    • admin role -> premium plan
    • teacher role -> pro plan
    • student role -> free plan
  7. 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:

ClaimDescription
subKeycloak user UUID
emailUser email address
nameFull name
preferred_usernameKeycloak username
realm_access.rolesArray of Keycloak realm roles
tenant_idTenant UUID (custom claim)
school_idSchool UUID (custom claim)
iat / expIssued-at / expiration timestamps

Token Verification

The auth middleware uses a two-step verification strategy:

  1. Keycloak JWKS verification (primary): Verifies the token signature against Keycloak's remote JWKS endpoint (/realms/{realm}/protocol/openid-connect/certs) using the jose library. Accepts issuers from both internal (Docker) and external Keycloak URLs.

  2. Local JWT_SECRET fallback: If JWKS verification fails, the middleware attempts verification using the JWT_SECRET environment 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:

  1. Internal API key check: If x-api-key header matches INTERNAL_API_KEY (timing-safe comparison), the request is authorized as an internal service call. The x-tenant-code header sets the tenant context.

  2. Token extraction: Reads the JWT from Authorization: Bearer <token> header or ?token= query parameter.

  3. Token verification: Tries Keycloak JWKS, then falls back to local JWT_SECRET.

  4. JIT user provisioning: Calls subscriptionService.getOrCreateUser() to ensure a local creatiq_users record exists. Enriches the JWT payload with the user's plan from the database. Resets monthly usage counters if the calendar month has changed.

  5. Request enrichment: Sets req.jwtPayload, req.user (H5P-compatible user object), and req.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:

  1. Looks up req.hostname against tenants.domain and tenants.custom_domain columns.
  2. If no match, extracts the subdomain from {slug}.{BASE_DOMAIN} and looks up by slug.
  3. Attaches the resolved Tenant object to req.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 RoleKeycloak Realm RolesPermissions
adminsuper_admin, tenant_admin, school_adminInstall/update H5P libraries, install recommended, create restricted content
teacherteacherInstall 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:

Permissionadminteacherstudent
canInstallRecommendedyesyesno
canUpdateAndInstallLibrariesyesnono
canCreateRestrictedyesyesno

Stripe Checkout & Billing Portal

Subscription Plans

PlanPriceContents/MonthAI Generations/MonthStorage
Free$035100 MB
Pro$4/mo301005 GB
Premium$8/moUnlimitedUnlimited50 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:

  1. Looks up the authenticated user in the local database.
  2. Creates a Stripe customer if one does not exist (stores stripe_customer_id).
  3. Creates a Stripe Checkout Session in subscription mode with the plan's stripePriceId.
  4. 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:

  1. Verifies the webhook signature using STRIPE_WEBHOOK_SECRET.
  2. Deduplicates events using Redis (webhook:{event_id} key with 24-hour TTL).
  3. Processes the event inside a database transaction.

Handled Event Types

EventAction
checkout.session.completedUpdates user's plan and stripe_subscription_id based on metadata.userId and metadata.planId
customer.subscription.deletedDowngrades user to free plan, clears stripe_subscription_id
invoice.payment_failedLogs 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.

TierMax Requests / 15 minApplied To
AUTH10/api/auth/login, /api/auth/refresh
AI50AI generation endpoints
API100General authenticated API endpoints
PUBLIC200Public/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_CODE is 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 FlagFreeProPremium
pdf-to-h5p-yesyes
video-to-h5p--yes
image-hotspot-yesyes
url-to-h5p-yesyes
blooms-critique-yesyes
differentiation-yesyes
bulk-generation-yesyes
lesson-bundle--yes
branching-scenario--yes
analytics-dashboard-yesyes
ai-recommendation--yes
content-remixer-yesyes
lti-integration--yes
lti-ags--yes
lti-nrps--yes
cmi5-launch-yesyes
whitelabel--yes
scorm-export-yesyes

Environment Variables

VariableDescriptionDefault
KEYCLOAK_BASE_URLInternal Keycloak base URLhttp://eduagentic-pass:8080
KEYCLOAK_EXTERNAL_URLExternal (browser-facing) Keycloak URLhttp://localhost:22020
KEYCLOAK_REALMKeycloak realm nameeduagentic
KEYCLOAK_CLIENT_IDKeycloak client IDcreatiq
KEYCLOAK_CLIENT_SECRETKeycloak client secret(required)
JWT_SECRETSecret for locally-issued tokens (cmi5 launch)(optional)
INTERNAL_API_KEYAPI key for service-to-service calls(optional)
STRIPE_SECRET_KEYStripe API secret key(optional, disables payments if unset)
STRIPE_WEBHOOK_SECRETStripe webhook signing secret(required for webhooks)
STRIPE_PRO_PRICE_IDStripe Price ID for the Pro plan(required for checkout)
STRIPE_PREMIUM_PRICE_IDStripe Price ID for the Premium plan(required for checkout)
NEXT_PUBLIC_APP_URLApplication URL for Stripe redirect URLshttp://localhost:17000
Back to docsdocs/product/reference/authentication.md