Skip to content

Authentication ​

The control plane supports two authentication mechanisms: JWT-based session auth for the dashboard and API token auth for client APIs. On the Enterprise tier, a tenant's dashboard login can additionally be backed by an external directory (LDAP) or an OIDC identity provider (SSO) instead of the local password — see External Identity Providers below.

Authentication Modes ​

ModeUsed ForMechanism
JWT SessionDashboard UIHTTP-only cookie
API TokenClient APIs (/api/client/v1/*)Authorization: Bearer <token>
LDAP (Enterprise)Dashboard UI, directory-backedLocal password path replaced per-tenant; same JWT session issued on success
SSO / OIDC (Enterprise)Dashboard UI, identity-provider-backedAuthorization-code + PKCE redirect flow; same JWT session issued on success
API Token + user tokenClient APIs, acting for your application's own signed-in usersAuthorization: Bearer <token> plus a signed X-Cognipeer-User-Token statement — see Acting for a user

JWT Session Authentication ​

Login Flow ​

  1. User submits slug + email + password to POST /api/auth/login
  2. Server finds tenant by slug, switches to tenant database
  3. Validates email/password against stored bcrypt hash
  4. Generates JWT with user info, license features, and tenant data
  5. Sets HTTP-only cookie (token)
  6. Returns user profile

Registration Flow ​

  1. User submits company name, slug, name, email, password to POST /api/auth/register
  2. Server creates tenant with the given slug
  3. Creates a new tenant_{slug} database
  4. Creates user as owner with hashed password
  5. Assigns default license (FREE)
  6. Generates JWT and sets cookie
  7. Sends welcome email

JWT Payload ​

typescript
{
  userId: string;
  email: string;
  role: 'owner' | 'admin' | 'project_admin' | 'user';
  tenantId: string;
  tenantSlug: string;
  tenantDbName: string;
  licenseType: string;
  features: string[];
}

The JWT is signed using the jose library (Edge Runtime compatible) with JWT_SECRET.

Middleware Processing ​

The global middleware (src/middleware.ts) processes every request:

Request → Is public path? → Pass through
        → Is client API? → Skip cookie auth (Bearer handled in route)
        → Extract cookie → Verify JWT → Check license endpoint access
        → Inject headers → Forward to route handler

Headers injected for authenticated requests:

HeaderContent
x-user-idUser ObjectId
x-user-emailUser email
x-user-roleowner, admin, project_admin, user
x-tenant-idTenant ObjectId
x-tenant-slugTenant slug
x-tenant-db-nametenant_{slug}
x-license-typeLicense tier
x-featuresJSON array of feature flags
x-request-idRequest UUID

Public Paths ​

These paths skip authentication:

  • /login, /register
  • /api/auth/* (includes the SSO login flow, /api/auth/sso/* — see below)
  • /api/health/*

API Token Authentication ​

For programmatic access, tenants create API tokens through the dashboard. These tokens authenticate requests to /api/client/v1/* endpoints.

Every token is owned by a user record, and that user's canLogin flag shapes where the token gets managed:

  • A regular member (canLogin: true, the default) mints and manages their own tokens under Configure → API Tokens.
  • A Programmatic User (canLogin: false) — a login-disabled identity with no password and an optional email, meant to exist only to own API tokens (a service account, a CI job, an integration) — can never sign in, so it can't reach that self-service page. An owner/admin instead manages its tokens from its row in Configure → Members → <user>, on that user's dedicated per-user token page.

For anything that isn't a human logging into the dashboard, prefer creating a Programmatic User and minting its tokens from that per-user page (or the equivalent Client API calls below) rather than handing out a real teammate's personal token. See User Types (canLogin) for how the flag interacts with roles.

Tokens are managed under Configure → API Tokens (your own tokens) or Configure → Members → <user> (any user's tokens, including a Programmatic User's). Each row shows the label, creation date, and last-used timestamp; the full secret is shown only once at creation time:

API Tokens

Usage ​

bash
curl -X POST https://gateway.example.com/api/client/v1/chat/completions \
  -H "Authorization: Bearer cpeer_abc123..." \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}'

Provisioning a non-login identity from the API ​

The per-user token page has a Client API equivalent — two endpoints, both requiring an owner/admin-owned token:

  1. POST /client/v1/users — creates a Programmatic User. canLogin is always forced to false server-side, so this endpoint can never create a login-capable account no matter what the request body sends.
  2. POST /client/v1/users/:id/tokens — mints an API token for that user (or any user in the tenant) and returns the plaintext secret once.
bash
# Create a login-disabled service account
curl -X POST https://gateway.example.com/api/client/v1/users \
  -H "Authorization: Bearer cpeer_admin_token..." \
  -H "Content-Type: application/json" \
  -d '{"name": "billing-sync-bot", "role": "user"}'

# Mint an API token for it (id from the response above)
curl -X POST https://gateway.example.com/api/client/v1/users/<user_id>/tokens \
  -H "Authorization: Bearer cpeer_admin_token..." \
  -H "Content-Type: application/json" \
  -d '{"label": "billing-sync nightly job"}'

See Users API for full request/response fields and error cases.

requireApiToken Helper ​

All client API routes use the requireApiToken helper:

typescript
import { requireApiToken, ApiTokenAuthError } from '@/lib/services/apiTokenAuth';

export async function POST(request: NextRequest) {
  try {
    const ctx = await requireApiToken(request);
    // ctx.token, ctx.tokenRecord, ctx.tenant
    // ctx.tenantId, ctx.tenantSlug, ctx.tenantDbName
    // ctx.projectId, ctx.user
  } catch (error) {
    if (error instanceof ApiTokenAuthError) {
      return NextResponse.json({ error: error.message }, { status: error.status });
    }
    throw error;
  }
}

Token Validation Flow ​

  1. Extract Bearer token from Authorization header
  2. Check cache (SHA-256 hash key, 60s TTL)
  3. On cache miss: query database, resolve tenant
  4. Fire-and-forget: update lastUsed timestamp
  5. Switch to tenant database
  6. Ensure default project exists
  7. Resolve user from token record
  8. Return ApiTokenContext

Token Properties ​

FieldDescription
tokenThe raw token string
userIdOwning user
tenantIdOwning tenant
projectIdScoped project (optional)
expiresAtExpiration date (optional)
lastUsedLast usage timestamp

Acting for a user (verified end-user identity) ​

Requires a Console release with the IAM engine

The X-Cognipeer-User-Token header and its per-token setup exist only in releases that include the IAM engine.

A token acts as its owner by default. An application that signs its own users in can instead let each call act as the person it is for: it keeps sending its API token and adds a signed statement about the person in the X-Cognipeer-User-Token header. Console verifies the statement (issuer, audience, pinned algorithm, signature, expiry), finds or creates the person's account, and runs the call with that person's access and limits, still capped by the token's scope and project. Without the header nothing changes; with it, a statement that is not accepted is refused and the owner is never the fallback.

Owners and admins set it up per token under Settings → IAM center → Principals → API tokens. See Acting for a user for the setup, the claims, the refusal codes and example code, and External users for the accounts it can create.

External Identity Providers (Enterprise) ​

Both integrations let a tenant's members sign in through a corporate identity system instead of a local password, and both just-in-time (JIT) provision a local user record on first successful login. They are configured per tenant from Configure → LDAP Directory and Configure → Single Sign-On, and require an active ENTERPRISE license on that tenant — a FREE/STARTER/PROFESSIONAL tenant sees an upgrade prompt instead of the configuration form, and the admin config/test endpoints (/api/ldap/config, /api/ldap/test, /api/sso/config, /api/sso/test) return 402 Payment Required. The login paths themselves are never license-gated — once configured, authentication keeps working even if the license check runs before every other request.

LDAP ​

Verifies the login against a directory (OpenLDAP, Active Directory, …): binds as a service account, searches for the user entry, then re-binds as that entry to verify the password — the directory always holds the real credential, console never sees or stores it.

FieldDescription
urlldap://ldap.corp:389 or ldaps://ldap.corp:636
bindDN / bindCredentialsService account used to search for the user entry (password encrypted at rest)
searchBaseSubtree to search for user entries
userFiltere.g. (|(uid={{username}})(mail={{username}})) — {{username}}/{{uid}} are replaced with the escaped login identifier
emailAttribute / nameAttributeDirectory attributes read into the local user record
defaultRole / adminGroupDNsTenant role for provisioned users; members of any admin group DN become tenant admin
groupSearchBase / groupMemberAttributeOptional — enables group→role resolution via a subtree search in addition to the user entry's own memberOf
syncGroups / ldapGroupMappingsOptional — reconciles console user groups (Members → Groups) from directory group membership on every login. Console groups are auto-created on first match; what a synced group grants (role, service permissions, project access) is still configured in the Groups admin UI — LDAP only owns membership, and never touches a group's local (hand-added) members
tlsRejectUnauthorizedSet to false to accept a self-signed certificate (ldaps://, dev only)

Login is slug-scoped — a directory user signs in with their organization's slug the same way a local user does; there is no cross-tenant directory lookup for LDAP identities on their first login. Password login for an existing account is refused (not silently allowed) once LDAP rejects a directory user, so a directory outage fails closed rather than falling back to a stale local password.

SSO (OIDC) ​

Authorization-code + PKCE (S256) against any OIDC-conformant provider — Keycloak, Okta, Entra ID, Auth0, Google Workspace — configured generically, with no per-provider code.

/login → "Sign in with SSO"
  → GET /api/auth/sso/discover
      exactly one tenant-wide connection enabled? → skip the email step, redirect straight to the IdP
      otherwise → show the email field
  → GET /api/auth/sso/start?email=…
      resolve the tenant by matching the email's domain against each connection's allowed domains
      → redirect to the identity provider with a signed, httpOnly state cookie (PKCE + nonce)
  IdP → GET /api/auth/sso/callback
      → exchange the code, verify the id_token against the provider's JWKS, resolve/provision the user
      → mint a short-lived, single-use ticket (the session token itself never appears in a URL)
      → redirect to /login/sso-callback?ticket=…
  browser → POST /api/auth/sso/session { ticket }
      → claims the ticket exactly once and issues the same JWT/cookie session as password login
FieldDescription
issuer / discoveryUrlProvider issuer; discovery defaults to {issuer}/.well-known/openid-configuration and is resolved once, at save time
clientId / clientSecret / clientAuthMethodConfidential client credentials (secret encrypted at rest)
scopes / usePkce / fetchUserInfo["openid","email","profile"] by default; PKCE on by default; optionally merge in /userinfo claims
subjectClaimThe stable identity anchor (default sub) — matched before email on every login, so a changed or synthesized email can never orphan the account
requireVerifiedEmailReject the login when the provider's email_verified claim is false
allowedEmailDomainsRoutes a login attempt to this tenant's connection; public mailbox domains (gmail.com, outlook.com, …) are rejected outright
defaultRole / adminClaimName / adminClaimValuesTenant role for provisioned users; a matching claim value maps to tenant admin

Email is not required. Two independent mechanisms cover directories that never send a usable email claim, or deployments with only one identity provider:

  • Synthetic email — configure a username claim (default preferred_username) and a synthetic domain; console builds {localpart}@{syntheticdomain} as a placeholder email. The account stays stable across a resynthesized email because it's matched by the subject claim, not the email.
  • Single-connection shortcut — when exactly one tenant-wide SSO connection is enabled (a single-tenant/on-prem deployment), the login page skips the email step entirely and redirects straight to the identity provider.

License-Based Feature Control ​

Features are controlled through a license system defined in src/config/policies.json:

typescript
import { LicenseManager } from '@/lib/license/license-manager';

const hasAccess = LicenseManager.hasFeature(licenseType, 'LLM_CHAT');
const canAccessEndpoint = LicenseManager.hasEndpointAccess(licenseType, '/api/models');

License Tiers ​

TierFeaturesRequest Limit
FREE16 features1,000/month
STARTER10 features10,000/month
PROFESSIONAL14 features100,000/month
ENTERPRISEAll featuresUnlimited
ON_PREMISEAll featuresUnlimited

Feature Endpoint Mapping ​

Each feature in policies.json maps to API endpoint patterns:

json
{
  "LLM_CHAT": {
    "name": "LLM Chat",
    "endpoints": ["/api/chat/*", "/api/client/v1/chat/*"]
  }
}

The middleware checks these mappings automatically.

User Roles ​

RoleScope
ownerFull tenant control
adminManage users and settings
project_adminManage assigned projects
userAccess assigned projects only

A role sets the default level of each service. What a person can actually do is the result of their role, their own service permissions, the grants of their groups, their project permissions and, for API tokens, the token's scope, all decided by one policy engine, with custom policies for exceptions. See Authorization (IAM) for how the levels combine, and Troubleshooting and FAQ for reading a refusal (403 with a decision id).

User Types (canLogin) ​

role and canLogin are independent axes on the same user record — role governs what a user can do once authenticated, canLogin governs whether it can authenticate as a dashboard session at all. A user of any role can be either type:

canLoginTypeMeaning
true (default — missing/undefined is treated as true for back-compat)Regular memberHas a password, can log into the dashboard and hold a JWT session, in addition to owning API tokens.
falseProgrammatic UserNo password, no dashboard session, email optional. Exists only to own API tokens — a service account, integration, or CI identity. Shown with a "No login" status badge and a "Programmatic" badge next to its role in Configure → Members.

POST /client/v1/users (the Client API) can only ever create canLogin: false users — it forces the flag server-side regardless of the request body, so an API token can never provision a login-capable account. A human teammate must still be added from the dashboard, with a password, via Configure → Members → Add User (with the Send invite toggle left on).

Configuration ​

VariableDefaultDescription
JWT_SECRET—Required. Secret for JWT signing
JWT_EXPIRES_IN7dJWT expiration duration
PROVIDER_ENCRYPTION_SECRETJWT_SECRETEncryption key for stored credentials

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub