Corteksa
GuidesIntegrations

Frontend Integration

Integrations — Frontend Integration

This is the contract the crm-monorepo frontend implements so that external apps (starting with wieeo) can offer a "Connect with Corteksa" button. The backend (Corteksa) is a full OAuth 2.0 provider; the frontend builds the consent UI and the connected-apps management UI on top of it.

Scope of this document. Everything here is about the first-party Corteksa web app (the logged-in workspace user). It does NOT describe the external partner app (wieeo) — that side (PKCE, token exchange, calling the API with the token) is summarized in §7 only so you understand the whole picture. The browser never touches client secrets or access tokens.


1. What the frontend builds

Two screens, both in the user app (apps/user, logged-in workspace user, Bearer-authenticated — the same auth every other CRM page uses):

#ScreenPurposeEndpoints
AConsent screenThe page an external app sends the user to. Shows "wieeo wants access to: Read contacts, Create contacts" with Approve / Deny.GET + POST /api/v1/oauth/authorize
BConnected apps (settings)The user reviews and revokes apps they've authorized.GET + DELETE /api/v1/oauth/authorizations

Optionally, a super-admin screen in apps/admin to register/manage partner apps (§6) — this is the only place apps get created.

Base URL & auth

  • All endpoints are under /api/v1 on the workspace's own subdomain — the exact host the frontend already calls. Use the existing apiClient so the tenant subdomain and the Authorization: Bearer <user JWT> header are attached automatically. No special base URL.
  • The two consent endpoints and both connected-apps endpoints require the logged-in user (same guard as the rest of the app). A 401 means "log in".

Response envelope

Every endpoint here except /oauth/token and /oauth/revoke returns the standard app envelope — read data:

{ "status": 200, "message": "Success", "data": { /* ... */ } }

(apiClient already unwraps this the way it does everywhere else.)


2.1 How the user arrives

The external app opens (in the user's browser) a URL on the workspace subdomain that points at your consent route, carrying standard OAuth query params:

https://{workspace}.corteksa.cloud/{locale}/oauth/authorize
    ?client_id=crtk_app_abc123
    &redirect_uri=https://wieeo.app/callback
    &scope=read.contacts%20create.contacts
    &state=xyz789
    &code_challenge=E9Melhoa2Ow...          (present for public/PKCE apps)
    &code_challenge_method=S256
    &response_type=code

Pick the frontend route (e.g. /[locale]/oauth/authorize, a blank-layout authenticated page — no dashboard chrome, but still behind login). The exact path is your choice; whatever you choose is what a super-admin registers as the partner's redirect target's origin — see the note in §6.

⚠️ Preserve every query param byte-for-byte. You will read them and pass them straight back to the backend on both the GET and the POST. Do not drop, reorder, or re-encode code_challenge / state — the backend re-derives security decisions from them and the partner app checks state itself.

2.2 Not logged in → login, then come back

The page requires auth. If the user isn't authenticated, redirect to login with a return URL back to this exact page including all query params, then return here after login. (Reuse the app's existing "redirect to login with return URL" mechanism — this is the same pattern as any other protected deep link.)

Call with the same query params the browser received:

GET /api/v1/oauth/authorize?client_id=…&redirect_uri=…&scope=…&state=…&code_challenge=…&code_challenge_method=S256
Authorization: Bearer <user JWT>   ← via apiClient

200 response data (ConsentDetails):

interface ConsentDetails {
  app: {
    name: string;            // "wieeo" — show prominently
    logo_url: string | null; // app icon, may be null → show a placeholder
    client_id: string;
  };
  scopes: Array<{
    route_name: string;      // "read.contacts" — machine id
    description: string;     // "Read contacts" — HUMAN label, show this
  }>;
  redirect_uri: string;      // echoed; show its host so the user sees where data goes
  state: string | null;      // echoed; pass back untouched on POST
  already_authorized: boolean; // true → user previously granted ≥ these scopes
}

Render:

  • App name + logo.
  • The host of redirect_uri (e.g. "You'll be returned to wieeo.app").
  • One row per scopes[i].description (a checklist of what's being granted).
  • Approve and Deny buttons.
  • If already_authorized === true, you may show "You've already connected this app" and still require an explicit Approve click (recommended), or auto-submit approve. Never silently approve new scopes.

Error responses on the GET — do NOT redirect anywhere:

StatusMeaningUX
400Bad request: unknown/inactive client_id, redirect_uri doesn't exactly match a registered URI, requested scope not allowed for the app, PKCE missing for a public client, response_typecode.Show a plain error screen ("This app can't be connected"). Never redirect the browser to redirect_uri — the backend deliberately withholds a redirect_to here (open-redirect defense).
401Not logged in / token expired.Send to login with return URL (§2.2).

2.4 Step 2 — submit the decision (POST)

On Approve or Deny, POST the decision. Send back the same params plus decision:

POST /api/v1/oauth/authorize
Authorization: Bearer <user JWT>
Content-Type: application/json
interface AuthorizeDecisionBody {
  client_id: string;
  redirect_uri: string;
  scope?: string;                 // space-separated, same as received
  state?: string;                 // pass through untouched
  code_challenge?: string;        // pass through untouched
  code_challenge_method?: 'S256';
  decision: 'approve' | 'deny';
}

200 response data:

interface AuthorizeDecisionResult {
  redirect_to: string;  // FULL url the browser must go to now
}
  • Approveredirect_to = {redirect_uri}?code=…&state=…
  • Denyredirect_to = {redirect_uri}?error=access_denied&state=…

In both cases, perform a full-page browser redirect to redirect_to:

window.location.assign(data.redirect_to); // NOT next/navigation router.push

Use a hard navigation (not a client-side route change) — you're leaving the Corteksa SPA and going to the partner's domain.

Error on the POST:

  • 400 — e.g. approve but none of the requested scopes are grantable by this user's permissions ("None of the requested scopes can be granted by your account"). Show the error; do not redirect.
  • 401 — login (§2.2).
Browser lands on /oauth/authorize?client_id&redirect_uri&scope&state&code_challenge
   │  (not logged in?) → login → back here (params preserved)
   ├─ GET  /api/v1/oauth/authorize  ──→ ConsentDetails  → render app + scopes
   │        └─ 400 → error screen, DO NOT redirect
   └─ user clicks Approve / Deny
         POST /api/v1/oauth/authorize {…, decision}  ──→ { redirect_to }
         └─ window.location.assign(redirect_to)   → back to wieeo (with code or error)

3. Screen B — connected apps (settings)

A user reviews and disconnects the apps they've authorized in this workspace. Put it under settings (e.g. Settings → Connected apps).

3.1 List

GET /api/v1/oauth/authorizations           Authorization: Bearer <user JWT>

200 dataConnectedApp[]:

interface ConnectedApp {
  slug: string;             // use for the revoke call
  app_client_id: string;
  app_name: string;         // "wieeo"
  app_logo_url: string | null;
  scopes: string[];         // route_names granted; humanize for display
  authorized_at: string;    // ISO timestamp
}

Render one card/row per app: logo, name, granted scopes, "Connected on {authorized_at}", and a Disconnect button.

3.2 Revoke (disconnect)

DELETE /api/v1/oauth/authorizations/{slug}   Authorization: Bearer <user JWT>

200{ "status": 200, "message": "Authorization revoked", "data": null }

Revoking cascades: it kills the grant and the app's live access/refresh tokens for this user immediately — the app loses access at once, not at token expiry. Confirm before calling (destructive), then refresh the list.

  • 404 → the grant doesn't exist (or isn't the caller's). Refresh the list.

4. Copy-paste TypeScript types

// ---- shared envelope (except /oauth/token, /oauth/revoke) ----
interface ApiEnvelope<T> { status: number; message: string; data: T; }

// ---- Screen A: consent ----
export interface ConsentScope { route_name: string; description: string; }
export interface ConsentDetails {
  app: { name: string; logo_url: string | null; client_id: string };
  scopes: ConsentScope[];
  redirect_uri: string;
  state: string | null;
  already_authorized: boolean;
}
export interface AuthorizeDecisionBody {
  client_id: string;
  redirect_uri: string;
  scope?: string;
  state?: string;
  code_challenge?: string;
  code_challenge_method?: 'S256';
  decision: 'approve' | 'deny';
}
export interface AuthorizeDecisionResult { redirect_to: string; }

// ---- Screen B: connected apps ----
export interface ConnectedApp {
  slug: string;
  app_client_id: string;
  app_name: string;
  app_logo_url: string | null;
  scopes: string[];
  authorized_at: string;
}

5. Endpoint reference (frontend-facing)

MethodPathAuthBody / QueryReturns (data)
GET/api/v1/oauth/authorizeuser Bearerquery: client_id, redirect_uri, scope?, state?, code_challenge?, code_challenge_method?, response_type?ConsentDetails
POST/api/v1/oauth/authorizeuser BearerAuthorizeDecisionBody{ redirect_to }
GET/api/v1/oauth/authorizationsuser BearerConnectedApp[]
DELETE/api/v1/oauth/authorizations/:sluguser Bearernull (message only)

6. (Optional) Super-admin app registry

Only a super-admin registers partner apps (curated model). If you build a UI for it, these are the endpoints (all Bearer + super-admin):

MethodPathBodyNotes
POST/api/v1/oauth/appsCreateOAuthApp (below)Returns client_id, and client_secret once for confidential apps — show a "copy now, won't be shown again" modal.
GET/api/v1/oauth/appsList all apps (OAuthApp[]).
PATCH/api/v1/oauth/apps/:slugpartial { redirect_uris?, allowed_scopes?, name?, description?, logo_url?, is_active? }Edit.
DELETE/api/v1/oauth/apps/:slugRemove the app + revoke all its tokens.
interface CreateOAuthApp {
  name: string;                         // ≤120 chars
  description?: string;                 // ≤500
  logo_url?: string;                    // valid URL, ≤500
  redirect_uris: string[];              // ≥1; EXACT https URIs (localhost http ok for dev)
  allowed_scopes: string[];             // ≥1 route_names, e.g. ["read.contacts","create.contacts"]
  client_type?: 'confidential' | 'public'; // default confidential; public ⇒ PKCE, no secret
}
// POST response data: { client_id: string; client_secret?: string; app: OAuthApp }
interface OAuthApp {
  slug: string; client_id: string; name: string; description: string | null;
  logo_url: string | null; redirect_uris: string[]; allowed_scopes: string[];
  client_type: 'confidential' | 'public'; is_active: boolean; created_at: string;
}

redirect_uris are matched exactly at authorize time. The origin the super-admin registers is the partner's real callback (e.g. https://wieeo.app/callback) — it is not the Corteksa consent route. The consent route is just where the browser renders the screen; the code is delivered to the partner's registered redirect_uri.

Scope catalog: allowed_scopes and the consent scope are route-name strings — the same permission ids the app already uses (read.contacts, create.deals, update.contacts, …). Reuse whatever permission-name catalog the permissions UI already has to pick scopes.


7. What the frontend does NOT build (the partner side)

For context only — this is wieeo's code, not crm-monorepo, and never runs in the Corteksa browser:

  1. wieeo generates a PKCE code_verifier + code_challenge and sends the user to the Corteksa consent route (§2.1).
  2. After the user approves, the browser lands on wieeo's redirect_uri with ?code=…&state=….
  3. wieeo's backend exchanges the code for tokens — server-to-server, with its client_secret (confidential) or code_verifier (public/PKCE):
    POST /api/v1/oauth/token   (raw RFC 6749 JSON — NO app envelope)
    { grant_type: "authorization_code", code, redirect_uri, client_id,
      client_secret | code_verifier }
    → { access_token: "crtk_oauth_…", token_type: "Bearer",
        expires_in: 3600, refresh_token: "crtk_refresh_…", scope }
  4. wieeo calls the CRM with the token: Authorization: Bearer crtk_oauth_…, refreshing via grant_type: "refresh_token", and can revoke via POST /api/v1/oauth/token/oauth/revoke.

The Corteksa browser SPA must never call /oauth/token, never handle a client_secret, and never see a crtk_oauth_ / crtk_refresh_ token. Its only job is the consent screen (which yields a short-lived code delivered to the partner) and the connected-apps management screen.


8. Security checklist for the frontend

  • ✅ Pass code_challenge, code_challenge_method, and state through untouched on both GET and POST.
  • ✅ Redirect only to the backend-returned data.redirect_to. Never build a redirect from the raw redirect_uri query param yourself.
  • ✅ On any 400 from /oauth/authorize, show an error screen — do not redirect the browser anywhere (open-redirect defense).
  • ✅ Use a hard navigation (window.location.assign) for redirect_to — you are leaving to the partner's domain.
  • ✅ The consent + connected-apps calls go through the normal apiClient (user Bearer, workspace subdomain). Nothing here needs a new base URL or a secret.
  • ❌ Never call /oauth/token or touch a client secret from the browser.

See also

  • ARCHITECTURE.md — the backend design (opaque tokens, RLS isolation, PKCE/secret verification, refresh rotation, cascade revoke). </content> </invoke>

On this page