Corteksa
GuidesIntegrations

Architecture

Integrations — Architecture

Phase 1 of the Corteksa integration platform. It lets an external app (the timer app wieeo, Zapier/Make, or any partner) authenticate into the CRM's REST API with a per-workspace key — the foundation the later OAuth "Connect" server reuses.

The model in one paragraph

A key acts as the admin who created it. On each request the guard hashes the presented key, finds its row, resolves { workspace_id, admin_id }, pins the RLS connection to that workspace, and attaches an AdminUser whose id is the acting admin. From there the existing PermissionGuard, row-level access filters, and audit all behave exactly as they would for that admin logging in — narrowed by the key's scopes and with the super-admin bypass forced off.

Authenticating a request

Send the key one of two ways:

X-Api-Key: crtk_live_<hex>
# or
Authorization: Bearer crtk_live_<hex>

The base URL is the workspace's own subdomain (same host the frontend uses) — the subdomain resolves the tenant DB; the key resolves the workspace within it.

Scopes = route names

A key's scopes are a subset of the same route_name strings the permission system already uses (read.contacts, create.deals, update.contacts, …). PermissionGuard checks the requested route's @RouteName against the key's scopes exactly like a human's permission list. A key can only be granted scopes its creator holds (super-admins may grant any).

Phase-1 surface: the records API — GET/POST/PUT/DELETE /api/v1/object/data/:objectSlug… (and its bulk variants). Admin-only endpoints (roles, billing, user management) are never reachable by a key.

Managing keys

Human-guarded admin endpoints (you cannot mint or revoke keys with a key):

MethodPathRoute nameNotes
POST/api/v1/api-keysapiKeys.createReturns the raw key once as data.api_key — copy it now.
GET/api/v1/api-keysapiKeys.readLists the workspace's keys (never the secret).
DELETE/api/v1/api-keys/:slugapiKeys.updateRevokes (kept as a row for audit).

POST body: { "name": "wieeo", "scopes": ["read.contacts","create.contacts"], "expires_at"?: "<ISO-8601>" }.

Security properties

  • Storage: only HMAC-SHA256(pepper, rawKey) is stored (deterministic → O(1) lookup; the key's 256-bit entropy makes a fast hash safe). Pepper is API_KEY_HASH_SECRET (falls back to JWT_SECRET). A DB dump yields nothing usable.
  • Isolation (hyper): the api_keys table carries workspace_id under a login-aware ws_isolation RLS policy — permissive only for the by-hash auth lookup (before a workspace is known), strict once the connection is pinned. Same carve-out the admin table uses. Dedicated tenants isolate by DB boundary.
  • No privilege escalation: isSuperAdmin is forced false, so a key never bypasses the endpoint gate. And because the record services always re-check the admin's current A/G/M/D level per record, a key's data access can never outlive the admin's rights — the scope list is only the coarse endpoint gate.
  • Rate limiting: a per-key fixed-window limiter (API_KEY_RATE_LIMIT_PER_MINUTE, default 120; 0 disables) applies to API-key traffic only, never human JWT requests.

Config

EnvDefaultPurpose
API_KEY_HASH_SECRETfalls back to JWT_SECRETHMAC pepper; rotate to invalidate all keys
API_KEY_RATE_LIMIT_PER_MINUTE120Per-key request budget; 0 disables

Phase 2 — OAuth 2.0 "Connect" server

Corteksa is also an OAuth 2.0 provider so a partner app can offer a "Connect with Corteksa" button. It reuses everything above: the issued access token is opaque (crtk_oauth_…, HMAC-hashed, login-aware-RLS tenant table) and flows through the SAME ApiAuthGuard → acting-as-admin → scopes path — the guard just routes by prefix (crtk_live_ = API key, crtk_oauth_ = OAuth). The shared TokenPrincipalService builds the acting-as-admin AdminUser for both.

The flow (authorization code + PKCE, RFC 6749/7636)

app → user browser → GET  /oauth/authorize?client_id&redirect_uri&scope&state&code_challenge
                      (SPA renders the consent screen from this JSON)
  user approves    → POST /oauth/authorize {decision:"approve", …}   [Bearer: the user]
                      → records the grant + mints a single-use code (Redis, 60s)
                      → { redirect_to: "{redirect_uri}?code=…&state=…" }
app backend → POST /oauth/token {grant_type:"authorization_code", code, redirect_uri,
                                 client_id, client_secret | code_verifier}
                      → validates single-use code + exact redirect + PKCE/secret
                      → { access_token: "crtk_oauth_…", refresh_token, expires_in, scope }
app → GET /api/v1/object/data/contacts   [Authorization: Bearer crtk_oauth_…]

Endpoints

Method / PathAuthPurpose
POST/GET/PATCH/DELETE /api/v1/oauth/appssuper-adminRegister/manage the global app (client) registry. Register returns client_id + client_secret once.
GET /api/v1/oauth/authorizeuser (Bearer)Consent details for the SPA (app + grantable scopes).
POST /api/v1/oauth/authorizeuser (Bearer)Approve/deny → returns redirect_to.
POST /api/v1/oauth/tokenclient (secret/PKCE)authorization_code + refresh_token grants. Raw RFC JSON.
POST /api/v1/oauth/revokeclientRFC 7009 token revocation.
GET/DELETE /api/v1/oauth/authorizations[/:slug]user (Bearer)"Connected apps": list / revoke consent (cascades to tokens).

Client types

  • confidential (server apps like wieeo's backend) — authenticate with client_secret.
  • public (SPAs/mobile) — no secret; PKCE (S256) required.

Security properties

  • Access tokens are revocable (opaque + hashed) — revoking a grant or app kills live tokens immediately, not just at expiry (~1h). Refresh tokens (~30d) are rotated on every use (old revoked).
  • Exact redirect_uri match against the registered allowlist (open-redirect defense); auth codes are single-use (atomic Redis GET-and-delete), 60s, bound to client + redirect + PKCE challenge.
  • Scope monotonic downgrade: granted ⊆ requested ⊆ app.allowed_scopes ⊆ the user's live permissions.
  • Wildcard object scopes ({verb}.*, ClickUp-style): a scope like read.* grants a verb across every object the connecting user can reach, so a partner can sync to any object the user later picks — without being re-registered per object. It stays bounded by the owner: at token-auth it expands (object-scope.util.ts expandObjectScopes) into only the owner's live {verb}.{slug} gates, and the record services still re-check A/G/M/D per row. It is grantable when the grantor holds any object gate for that verb (isScopeHeld).
  • The apps registry is a global main-DB catalog; grants + tokens are workspace-scoped with RLS (login-aware for tokens, strict for grants), the same isolation model as API keys. Token writes from the public token endpoint run inside TenantScope.runInScope (pins + releases the RLS connection).

Partner integration (how wieeo connects)

  1. A Corteksa super-admin registers the app (POST /oauth/apps with redirect_uris, allowed_scopes, client_type) → gets client_id (+ client_secret for confidential).
  2. wieeo sends the user to …/oauth/authorize?client_id&redirect_uri&scope&state&code_challenge on the workspace's subdomain.
  3. On approval, wieeo receives ?code=… at its redirect_uri, exchanges it at /oauth/token, and calls the records API with the crtk_oauth_ access token — refreshing via the refresh_token grant.

Push (webhooks)

Authentication above is the pull half (the app calls Corteksa). The push half — Corteksa sending HMAC-signed webhooks to the app on record changes — is its own doc: Webhooks. A public webhook subscription is simply a webhook owned by an API key; deliveries are signed, and an event caused by the app's own key is never echoed back to it. For the whole platform at a glance, see the Overview.

Still out of scope (later phases)

Self-serve developer portal (dynamic client registration), OIDC id_tokens, webhook auto-disable/health ledger, connector registry, and full OpenAPI generation.

On this page