Backend Integration
Integrations — Backend Integration
For backend engineers extending or maintaining the external developer
surface — API-key auth, scopes, and the OAuth "Connect" server. Everything below
lives under src/api/v1/developers/ (plus the guard in src/common/guards/).
One gate for humans and apps
Every request — a logged-in admin or an external app — enters through a single
guard, ApiAuthGuard (src/common/guards/api-auth.guard.ts), which extends
AdminAuthGuard. It classifies the credential by prefix
(classifyCortexaToken, api-key.util.ts) and routes it:
| Credential | Prefix | Handler |
|---|---|---|
| API key | crtk_live_… | ApiKeyAuthService (api-key-auth.service.ts) |
| OAuth access token | crtk_oauth_… | OAuthTokenAuthService (oauth/oauth-token-auth.service.ts) |
| Human JWT | (none of the above) | super.canActivate → AdminAuthGuard unchanged |
Every branch attaches the same acting-as-admin AdminUser, so the
downstream PermissionGuard and row-level filters behave identically regardless
of who authenticated.
The API-key auth path
ApiKeyAuthService.authenticate:
extractApiKey(req)— readsX-Api-KeyorAuthorization: Bearer crtk_….ApiKeysService.findByRawKey(raw)— hashes the key and does an O(1) by-hash lookup; returnsnullfor unknown / revoked / expired keys → 401.ApiKeyRateLimiter.check(key.id)— per-key fixed window → 429 if over.- Stamps
req.apiKeyId = key.id(so the webhook fanout can skip echoing the key's own writes), then hands off toTokenPrincipalService.establish.
TokenPrincipalService.establish (token-principal.service.ts) is the shared
projection used by both the API-key and OAuth paths, so acting-as-admin
behaviour can't drift:
- On hyper, pins the RLS connection to the credential's
workspace_id(pinWorkspaceConnection); a hyper token with no workspace fails closed. - Builds an
AdminUserthat acts asadmin_id, withisSuperAdmin: false(tokens never inherit the super-admin bypass) andpermissions = expandObjectScopes(scopes, snapshot.routeNames).
Enforcement is two layers
- Endpoint gate (coarse):
PermissionGuard+@RouteName('{verb}.{slug}')checks the credential's scopes — the same mechanism as a human's permission list. A call outside the granted scopes → 403. - Row-level scoping (fine): even past the gate, the record services re-check the acting admin's current A/G/M/D level per record (access-filter.builder.ts). A credential's data access can never outlive the admin's rights — the scope list is only the gate.
See Authorization for the A/G/M/D model.
The scope model
Scopes are route-name strings: {verb}.{objectSlug} — read.contacts,
create.deals, update.contacts, delete.contacts. Two rules, both in
object-scope.util.ts:
- Wildcards —
{verb}.*(e.g.read.*) grants a verb across every object the owner can reach.expandObjectScopesresolves it at auth time against the owner's live object gates (never more), so a broad grant stays bounded. - Grant check —
isScopeHeldgates what a key/app may be granted. A key can never carry a scope its creator doesn't hold;ApiKeysService.resolveScopesenforcesrequested ⊆ creator.permissions(super-admins may grant any).
Secret storage & hashing
TokenHashService (token-hash.service.ts) is the single owner of how the
platform hashes every secret — API keys, OAuth client secrets, access/refresh
tokens, and auth codes. All are ≥256-bit random, so a deterministic
HMAC-SHA256(pepper, raw) is safe and O(1)-lookup-able. Pepper =
API_KEY_HASH_SECRET (falls back to JWT_SECRET). Only the hash is stored — a
DB dump yields nothing usable (see ApiKey, api-key.entity.ts).
The OAuth token flow (server-side)
The token endpoint (POST /oauth/token, oauth/oauth-token.controller.ts) is
public — the client authenticates, not a user (@SkipAllGuards,
@SkipTransform so the body is raw RFC 6749 JSON). OAuthTokenService.exchange
(oauth/oauth-token.service.ts) handles two grants:
| Grant | Verifies | Result |
|---|---|---|
authorization_code | single-use code (Redis, 60s), exact redirect_uri, PKCE code_verifier (public) or client_secret (confidential) | new access + refresh pair |
refresh_token | client_secret (confidential), live refresh row | rotates — revokes old, mints new pair |
Details worth knowing:
- Opaque + revocable. Access tokens are
crtk_oauth_…(1h TTL,ACCESS_TOKEN_TTL_SECONDS), refresh tokenscrtk_refresh_…(30d). Revoking a grant/app kills live tokens immediately, not at expiry. - Refresh rotation + reuse detection. A presented refresh token that was
already rotated is treated as a leak (OAuth 2.1):
detectRefreshReuserevokes the whole token family. - Token writes run inside
TenantScope.runInScope— the rows are workspace-scoped, so writes from the public endpoint pin (and release) the RLS connection, exactly like the Gmail OAuth callback.
Access-token authentication (a partner calling the API) is the OAuth twin of
the API-key path: OAuthTokenAuthService.authenticate does an unpinned by-hash
lookup, checks revoked/expired, stamps req.oauthClientId, and calls the shared
TokenPrincipalService.establish.
Where to look
| Concern | File |
|---|---|
| Single credential gate | src/common/guards/api-auth.guard.ts |
| Key classification / extraction | developers/api-key.util.ts |
| API-key auth | developers/api-key-auth.service.ts |
| Key mint / revoke / lookup | developers/api-keys.service.ts |
| Acting-as-admin projection | developers/token-principal.service.ts |
| Scope wildcards & grant check | developers/object-scope.util.ts |
| Secret hashing | developers/token-hash.service.ts |
| Per-key rate limit | developers/api-key-rate-limiter.service.ts |
| OAuth token exchange | developers/oauth/oauth-token.service.ts |
| OAuth token auth | developers/oauth/oauth-token-auth.service.ts |
| Consent (authorize) | developers/oauth/oauth-authorize.controller.ts |
| App registry (super-admin) | developers/oauth/oauth-app.controller.ts |
| Module wiring | developers/developers.module.ts |
Module wiring & multi-tenant safety
DevelopersModule is @Global() — ApiAuthGuard is applied per-controller
across the app (records, objects, fields, webhooks), so its two dependencies
(ApiKeyAuthService, OAuthTokenAuthService) must resolve everywhere. Opening a
new controller to apps is a one-line guard swap, no imports: wiring. Those
two services are the only exports — everything else stays encapsulated.
All DB access flows through the pinned ContextAwareRepositoryProvider. The
api_keys and OAuth token tables carry workspace_id under a login-aware
RLS policy — permissive only for the by-hash auth lookup (before a workspace is
known), strict once pinned. Never reach past the pinned connection to the root
DataSource.
Next
- The scope-per-object model → API Keys architecture
- Push channel internals → Webhooks
- Every endpoint → REST API