## Quickest path

```bash
npm install @cosmoops/connect-sdk
```

```ts
const connect = new ConnectClient({ apiKey, clientId });

// send them out
redirect(connect.identity.authorizeUrl({ redirectUri, state: createState() }));

// back at your callback
const session = await connect.identity.exchangeCode({ code, redirectUri });
await connect.identity.verifyIdToken(session.idToken);

// on every later request
const { user, role, permissions, staff } = await connect.identity.getUser(session.sessionToken);
```

The rest of this section is the HTTP underneath, for languages the SDK does not cover.

## One credential, three jobs

The API key plaintext from the Integration tab is *also* your OAuth2 `client_secret` and the key your `id_token` is signed with. Revoking it revokes all three.

| What you hold | Lives for | Used for |
| --- | --- | --- |
| `client_id` | forever | public; goes in the authorize URL |
| API key plaintext | until revoked | `client_secret`, `id_token` signature, `Authorization: Bearer` |
| `code` | one use, ~15 min | exchanged at `POST /sso/token` |
| `id_token` | ~5 min | verified once at the callback, then discarded |
| `session_token` | ~15 days | **store this** - re-read identity at `POST /sso/user` |

## The flow

**1. Set up, once.** In the Integration tab, under **Connect client**: register the exact callback URL your backend receives `?code=` on as a redirect URI, and create an API key. There is no separate "enable" step - a saved Connect client is a working one.

**2. Send the user's browser to Connect.**

```http
GET /sso/authorize?client_id=YOUR_CLIENT_ID
  &redirect_uri=YOUR_REDIRECT_URI
  &response_type=code&scope=openid
  &state=RANDOM_PER_REQUEST
```

`state` must be fresh per request and checked on return - it is your CSRF protection. Add `&prompt=create` to open registration instead of sign-in. Add `&code_challenge=...&code_challenge_method=S256` if this application requires PKCE.

**3. Handle the callback.** Check `state` first, then branch on one field:

| Query param | What happened | What to do |
| --- | --- | --- |
| `code` | success | exchange it server-side |
| `error=login_required` | signup outran the ~15 min window - the user **is** signed in now | retry step 2 with a fresh `state`, show no error |
| `error=access_denied` | the user declined | show your own "cancelled" state |
| `error=` anything else | fatal for this attempt | log `error_description`, let them retry |

**4. Exchange the code** at `POST /sso/token`, server-to-server. Never from a browser.

**5. Verify the `id_token`** once, then discard it - HS256 over `header.payload`, keyed with your API key. There is no JWKS to fetch. `identity.verifyIdToken()` does this, including the `exp` and `aud` checks.

**6. Authorize on `permissions`** - permission ids from your Permission Catalog. Connect never checks them; your backend does.

**6b. Branch on `staff` first, if you have an operator console.** `staff: true` is one of *Connect's* own staff signing in - not a customer. Those accounts belong to no workspace (Connect enforces it; they cannot own or join one), so the session carries `user.workspaceId === null` and `subscription: null`. `staff` is also what decides which vocabulary the other two fields speak: your Permission Catalog's ids for a member, Connect's `platform:*` ids for an operator. Read it before either of them.

```ts
const { user, role, permissions, staff } = await connect.identity.getUser(sessionToken);

if (staff) {
  if (role !== "platform_admin") return forbidden();
  return renderOperatorConsole();          // no tenant - do not read workspace data
}

// ordinary customer: user.workspaceId is a string here
if (!permissions.includes("link:manage")) return forbidden();
```

On a staff session `role` is the highest platform role *your application accepts*, never Connect's full list for that person. Staff access is **off by default and per-application** - ask a Connect platform admin to enable it on your catalogue entry, naming the roles you accept. Until then `staff` is always `false` and this branch is dead code.

Re-read it live. A revoked role, or staff access switched off, comes back `staff: false` from `POST /sso/user` *and* Connect revokes the session on that read - a console trusting a login-time copy keeps serving someone already ejected.

**7. Store `session_token`, not `id_token`.** `POST /sso/user` trades it for the same payload, re-read live. Keep it server-side.

**8. Call `POST /sso/logout`** when your own user signs out.

## Endpoints

### POST `/api/v1/application/sso/token`

Exchange an authorization code for an ID token

**Scope** `sso` &nbsp;·&nbsp; **Rate limit** 30 requests / 5 min per IP, 20 requests / min per client_id

Step 4 of the Identity flow above: trade the one-time `code` your `redirect_uri` received for an `id_token` (verify once) and a `session_token` (store).

Server-to-server only - never exchange a `code` from a browser. Body may be `application/x-www-form-urlencoded` (the OAuth2 default) or `application/json`; credentials may go in the body or as `Authorization: Basic base64(client_id:client_secret)`, where `client_secret` is your API key plaintext.

```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/sso/token   -H "Content-Type: application/x-www-form-urlencoded"   -d grant_type=authorization_code   -d code=THE_CODE_FROM_THE_REDIRECT   -d redirect_uri=YOUR_REDIRECT_URI   -d client_id=YOUR_CLIENT_ID   -d client_secret=YOUR_API_KEY_PLAINTEXT
```

`subscription`, `role`, `permissions` and `staff` are snapshots as of this moment, deliberately not baked into the JWT - re-read them from `POST /sso/user` rather than trusting a cached copy.

**`staff: true` is a Connect operator, not your customer.** That session has `user.workspaceId === null` and `subscription: null` - there is no tenant and nothing is being paid for. Read `staff` before `role` or `permissions`: it decides whether those two carry your application's vocabulary or Connect's. Serve your operator console off that session and nothing else. It is only ever `true` when a platform admin has enabled staff access on your catalogue entry for that role; until then you will never see it.

**Errors** are OAuth2-flavoured here, not the `{ error: { code, message } }` envelope the rest of this reference uses. These are this endpoint's own failures; for an `error` on your `redirect_uri` instead, see the callback table in the Identity guide above.

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `grant_type` | `string` | yes |  |
| `code` | `string` | yes |  |
| `redirect_uri` | `uri` | yes |  |
| `code_verifier` | `string` | no | Required when the authorizing request set a code_challenge (PKCE) |
| `client_id` | `string` | no | May be sent here, or as Authorization: Basic base64(client_id:client_secret). Opaque - treat it as a string; clients issued before the rename carry an sso_ prefix, newer ones client_ |
| `client_secret` | `string` | no | The API key plaintext issued in the Integration tab |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Token issued |
| `400` | invalid_request / invalid_grant |
| `401` | invalid_client - unknown client_id, wrong client_secret, or a key with scopes set that don't include sso |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id_token` | `string` | yes | 3-part JWT, HS256-signed with your client_secret - no JWKS to fetch |
| `token_type` | `string` | yes |  |
| `expires_in` | `integer` | yes | Seconds until id_token expires - about 5 minutes |
| `session_token` | `string` | yes | Opaque, revocable handle on this sign-in - store this, and use it at /sso/user to re-read identity after id_token expires, or /sso/logout to end it |
| `session_token_expires_in` | `integer` | yes | Seconds until session_token expires - about 15 days |
| `user` | `object` | yes |  |
| `subscription` | `any` | yes |  |
| `role` | `any` | yes | The one role this session holds, or null. When staff is false: "admin", a role key from this application's Roles catalog, or null when the granted permissions match no single defined role. When staff is true: the highest Connect platform role this application accepts (platform_admin \| platform_manager \| developer) - never Connect's full list for that person. |
| `permissions` | `array` | yes | What to gate on. Permission ids from this application's Permission Catalog when staff is false; Connect platform:* ids when staff is true. One vocabulary at a time - read staff first. |
| `staff` | `boolean` | yes | true when this is one of Connect's own operators rather than your customer, and false on every ordinary member session. Only ever true when a Connect operator signs in AND this application has staff access enabled for their role. Such a session carries no workspaceId and no subscription - serve an operator console off it and nothing else. |

### POST `/api/v1/application/sso/user`

Read the current user for a stored session token

**Scope** `sso` &nbsp;·&nbsp; **Rate limit** 3,000 requests / min per client_id (30,000 / 5 min per IP)

Answers "who is signed in?" once `id_token` has expired - the same `user`, `subscription`, `role`, `permissions`, `staff` and `audience` fields as `/sso/token`, re-resolved live on every call rather than frozen at sign-in.

```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/sso/user   -H "Content-Type: application/x-www-form-urlencoded"   -d session_token=STORED_SESSION_TOKEN   -d client_id=YOUR_CLIENT_ID   -d client_secret=YOUR_API_KEY_PLAINTEXT
```

`user` is low-churn and fine to cache in your own session. `subscription`, `role`, `permissions` and `staff` are the volatile ones - gate anything that matters on a live call, which is the whole reason this endpoint exists.

`staff` is the sharpest of them: a revoked platform role, or staff access switched off for your application, comes back `false` here *and* Connect revokes the session on the same read. An operator console that trusts a login-time copy keeps serving someone Connect has already ejected.

`session_token` rather than a Bearer `id_token` because Connect stores only your secret's sha256 hash and so cannot verify a bare JWT - you would have to send the secret anyway. An opaque token is then strictly better: nothing verifies offline, so every read re-resolves live, and it can be revoked.

**Errors.** Every way a session can stop being valid - unknown, expired, revoked, issued to another application, or revoked just now because the member was removed - collapses into one `invalid_token` (401). Send the user back through `/sso/authorize`; a finer taxonomy would only be an oracle. Watch `session_expires_at` to refresh proactively instead.

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `session_token` | `string` | yes | The session_token returned by /sso/token |
| `client_id` | `string` | no | May be sent here, or as Authorization: Basic base64(client_id:client_secret) |
| `client_secret` | `string` | no | The API key plaintext issued in the Integration tab |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Current identity, subscription and role |
| `400` | invalid_request - malformed or empty body |
| `401` | invalid_client - bad credentials; invalid_token - session unknown, expired, revoked, or issued to another application |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `user` | `object` | yes |  |
| `subscription` | `any` | yes | Live, not the value captured at sign-in |
| `role` | `any` | yes | The one role this session holds, or null. When staff is false: "admin", a role key from this application's Roles catalog, or null when the granted permissions match no single defined role. When staff is true: the highest Connect platform role this application accepts (platform_admin \| platform_manager \| developer) - never Connect's full list for that person. |
| `permissions` | `array` | yes | What to gate on. Permission ids from this application's Permission Catalog when staff is false; Connect platform:* ids when staff is true. One vocabulary at a time - read staff first. |
| `staff` | `boolean` | yes | true when this is one of Connect's own operators rather than your customer, and false on every ordinary member session. Only ever true when a Connect operator signs in AND this application has staff access enabled for their role. Such a session carries no workspaceId and no subscription - serve an operator console off it and nothing else. |
| `session_expires_at` | `string` | yes | ISO 8601 - when session_token lapses |

### POST `/api/v1/application/sso/logout`

Revoke a session token

**Scope** `sso` &nbsp;·&nbsp; **Rate limit** 60 requests / 5 min per IP, 60 requests / min per client_id

Ends a sign-in immediately, instead of leaving it usable against `/sso/user` for the rest of its ~15-day life. Call it the moment your own user logs out, and discard your stored `session_token` at the same time - this call does not do that for you.

```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/sso/logout   -H "Content-Type: application/x-www-form-urlencoded"   -d session_token=STORED_SESSION_TOKEN   -d client_id=YOUR_CLIENT_ID   -d client_secret=YOUR_API_KEY_PLAINTEXT
```

**`scope` reaches further than your own token.** Pick deliberately:

| `scope` | Ends | Use for |
| --- | --- | --- |
| `device` (default) | this sign-in, every other application signed into from that same browser, and Connect's own session on that one device. Other devices untouched. | your ordinary "Log out" button |
| `all` | every session, every application, every workspace, every device, Connect included | an explicit "sign out everywhere" action only |

`device` is the default because a user who reached you through "Login with Connect" probably also has Connect's dashboard, or another application, open in that same browser - logging out of your app alone would leave them signed in beside it.

**The response is always `{ "success": true }`.** An unknown token, an already-revoked one, and one issued to a different application are indistinguishable no-ops from your side - same "no oracle" posture as `/sso/user`'s collapsed `invalid_token`. There is nothing to branch on: call it, discard your copy, done. Only bad `client_id`/`client_secret` (401 `invalid_client`), a malformed body (400) or throttling (429) fail.

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `session_token` | `string` | yes | The session_token to revoke |
| `client_id` | `string` | no | May be sent here, or as Authorization: Basic base64(client_id:client_secret) |
| `client_secret` | `string` | no | The API key plaintext issued in the Integration tab |
| `scope` | `device` \| `all` | no | "device" (default) also ends every other application and Connect on this same browser; "all" ends every session everywhere |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Always success - see the description above for why |
| `400` | invalid_request - malformed or empty body |
| `401` | invalid_client - bad client_id/client_secret |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `success` | `boolean` | yes |  |

### POST `/api/v1/application/sso/sessions`

List this Connect account's login sessions

**Scope** `sessions:manage` &nbsp;·&nbsp; **Rate limit** 60 requests / 5 min per IP, 60 requests / min per client_id

Every active Connect session for the account behind `session_token` - device, approximate location, and timestamps - the same list Connect's own account settings page shows to the user themselves.

**This is account-wide, not scoped to your application.** A Connect session is one browser's login to Connect itself, and one session can back sign-ins to *every* application the user has ever authorized from that browser - Connect has no per-application session concept to filter by. Requesting this scope means asking for visibility into a user's sessions with other, unrelated applications, not just your own. Gated behind its own `sessions:manage` scope for exactly that reason - unlike `sso`, no key gets it by default, grandfathered or otherwise.

**Every row is personal data**, including the raw `ip` the session signed in from and its `ipCity`/`ipCountry` - for sessions created through other applications as well as yours. Treat the response as you would the user's own account page: show it to the user it belongs to, do not retain it beyond the render, and do not log it.

```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/sso/sessions   -H "Content-Type: application/x-www-form-urlencoded"   -d session_token=STORED_SESSION_TOKEN   -d client_id=YOUR_CLIENT_ID   -d client_secret=YOUR_API_KEY_PLAINTEXT
```

`isCurrent` marks the one session your `session_token` was minted from - use it to badge "this device" rather than assuming list order.

**Errors.** `invalid_client` (401) for bad credentials or a key not scoped to `sessions:manage`; `invalid_token` (401) for any way `session_token` itself can be invalid (unknown, expired, revoked, issued to another application) - collapsed into one code, same "no oracle" posture as `/sso/user`.

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `session_token` | `string` | yes | The session_token returned by /sso/token |
| `client_id` | `string` | no | May be sent here, or as Authorization: Basic base64(client_id:client_secret) |
| `client_secret` | `string` | no | The API key plaintext issued in the Integration tab |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | This account's active sessions |
| `400` | invalid_request - malformed or empty body |
| `401` | invalid_client - bad credentials or missing sessions:manage scope; invalid_token - session_token itself is invalid |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `items` | `array` | yes |  |

### POST `/api/v1/application/sso/sessions/revoke`

Revoke one of this Connect account's login sessions

**Scope** `sessions:manage` &nbsp;·&nbsp; **Rate limit** 60 requests / 5 min per IP, 20 requests / min per client_id

Ends one specific Connect session by id - not necessarily the one `session_token` itself was minted from. Same cascade as Connect's own "sign out this device": every partner-application sign-in that originated from that browser ends with it, not just yours.

```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/sso/sessions/revoke   -H "Content-Type: application/x-www-form-urlencoded"   -d session_token=STORED_SESSION_TOKEN   -d sessionId=A_SESSION_ID_FROM_GET_SSO_SESSIONS   -d client_id=YOUR_CLIENT_ID   -d client_secret=YOUR_API_KEY_PLAINTEXT
```

**Errors.** `invalid_client` for bad credentials/scope; `invalid_token` for an invalid `session_token`; `session_not_found` (404) for a `sessionId` that doesn't exist, isn't this account's, or is already revoked - deliberately a *different* code from `invalid_token`, because this failure is about the *target* session, not the caller's own `session_token`, and conflating the two would make a valid caller's own session look expired.

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `session_token` | `string` | yes | The session_token returned by /sso/token |
| `client_id` | `string` | no | May be sent here, or as Authorization: Basic base64(client_id:client_secret) |
| `client_secret` | `string` | no | The API key plaintext issued in the Integration tab |
| `sessionId` | `string` | yes | A session id from GET /sso/sessions's response |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Revoked |
| `400` | invalid_request - malformed or empty body |
| `401` | invalid_client - bad credentials or missing sessions:manage scope; invalid_token - session_token itself is invalid |
| `404` | session_not_found - sessionId doesn't exist, isn't this account's, or is already revoked |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `success` | `boolean` | yes |  |

### POST `/api/v1/application/sso/sessions/revoke-others`

Revoke every other login session on this Connect account

**Scope** `sessions:manage` &nbsp;·&nbsp; **Rate limit** 60 requests / 5 min per IP, 20 requests / min per client_id

"Sign out everywhere else" - keeps the session `session_token` was minted from, revokes every other active session on the account.

```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/sso/sessions/revoke-others   -H "Content-Type: application/x-www-form-urlencoded"   -d session_token=STORED_SESSION_TOKEN   -d client_id=YOUR_CLIENT_ID   -d client_secret=YOUR_API_KEY_PLAINTEXT
```

`revokedCount` is `0` when the current session was already the only one - not an error.

**Errors.** `invalid_client` for bad credentials/scope; `invalid_token` for an invalid `session_token`.

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `session_token` | `string` | yes | The session_token returned by /sso/token |
| `client_id` | `string` | no | May be sent here, or as Authorization: Basic base64(client_id:client_secret) |
| `client_secret` | `string` | no | The API key plaintext issued in the Integration tab |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Revoked every other session |
| `400` | invalid_request - malformed or empty body |
| `401` | invalid_client - bad credentials or missing sessions:manage scope; invalid_token - session_token itself is invalid |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `success` | `boolean` | yes |  |
| `revokedCount` | `integer` | yes |  |