Identity
"Login with Connect" - the OAuth2 handshake, plus the session lifecycle that outlives it.
Quickest path
npm install @cosmoops/connect-sdkconst 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.
GET /sso/authorize?client_id=YOUR_CLIENT_ID
&redirect_uri=YOUR_REDIRECT_URI
&response_type=code&scope=openid
&state=RANDOM_PER_REQUESTstate 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.
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 · 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.
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_PLAINTEXTsubscription, 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 · 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.
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_PLAINTEXTuser 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 · 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.
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_PLAINTEXTscope 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 · 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.
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_PLAINTEXTisCurrent 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 · 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.
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_PLAINTEXTErrors. 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 · 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.
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_PLAINTEXTrevokedCount 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 |