CI HUBCI HUB SDK
AuthenticationCI HUB Auth

Exchange token

Exchange a partner-signed JWT for a CI HUB access token and refresh token.

POST
/auth/exchangeToken

Exchanges a partner-signed JWT for CI HUB access and refresh tokens. Required as the first call of every Access SDK session.

Partner JWT

Send the partner-signed JWT as Authorization: Bearer <partner-signed-jwt>.

Header

FieldRequiredValue
algyesRS256
kidyesMust match a key published in the partner JWKS
typoptionalJWT

HS256 is not accepted.

Payload

ClaimTypeRequiredNotes
issstringyesPartner issuer URL. Must match the registered value exactly, including trailing slash.
audstringyesRegistered audience. Default https://api.ci-hub.com.
substringyesStable identifier for the user in the partner system.
iatnumberyesUnix seconds. Tolerance: up to 30 seconds in the future. The token is rejected once it is older than maxTokenAge (now minus iat).
expnumberyesUnix seconds. Must be in the future.
emailstringyes (in JWT or body)Used for just-in-time (JIT) user resolution: CI HUB finds the matching user or creates one on first exchange. Must parse as an email. JWT value takes precedence over body.
given_namestringoptionalFirst name. Falls back to splitting name.
family_namestringoptionalLast name. Falls back to splitting name.
namestringoptionalDisplay name. Used when given_name and family_name are absent.

The token must be no older than maxTokenAge seconds (default 3600), measured from iat to the current time. Sign a fresh JWT for each exchange. Stale tokens are rejected with cihub-sdk-token-invalid.

Send Content-Type: application/json so the JSON body parser picks up the request. Only an application/json body is parsed for the email field; other content types leave it unread. Name fields are not read from the body. They come from the partner JWT claims (given_name, family_name, name).

Authorization

SdkPartnerAuth
AuthorizationBearer <token>

Partner-signed RS256 JWT verified against the partner's registered JWKS. Needs to be sent in the Authorization header. Only used by the token exchange.

In: header

Request Body

application/json

Optional. Used only as a fallback for partners that cannot include the email claim in the partner JWT. If the JWT contains email, the body value is ignored. The request must include email somewhere (JWT or body). If your JWT already carries email, send {}.

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/auth/exchangeToken" \  -H "Content-Type: application/json" \  -d '{}'
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_in": 3600,
  "token_type": "Bearer"
}
{
  "message": "Error",
  "details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
  "errorCode": "cihub-sdk-token-invalid",
  "error": {
    "code": "integration-forbidden",
    "source": "cihub",
    "status": 400,
    "message": "Access denied by the integration",
    "details": "403 Forbidden - insufficient_permissions",
    "provider": "bynder"
  }
}
{
  "message": "Error",
  "details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
  "errorCode": "cihub-sdk-token-invalid",
  "error": {
    "code": "integration-forbidden",
    "source": "cihub",
    "status": 400,
    "message": "Access denied by the integration",
    "details": "403 Forbidden - insufficient_permissions",
    "provider": "bynder"
  }
}
{
  "message": "Error",
  "details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
  "errorCode": "cihub-sdk-token-invalid",
  "error": {
    "code": "integration-forbidden",
    "source": "cihub",
    "status": 400,
    "message": "Access denied by the integration",
    "details": "403 Forbidden - insufficient_permissions",
    "provider": "bynder"
  }
}
{
  "message": "Error",
  "details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
  "errorCode": "cihub-sdk-token-invalid",
  "error": {
    "code": "integration-forbidden",
    "source": "cihub",
    "status": 400,
    "message": "Access denied by the integration",
    "details": "403 Forbidden - insufficient_permissions",
    "provider": "bynder"
  }
}
{
  "message": "Error",
  "details": "POST /api/v1/auth/exchangeToken failed: SDK authentication token is invalid",
  "errorCode": "cihub-sdk-token-invalid",
  "error": {
    "code": "integration-forbidden",
    "source": "cihub",
    "status": 400,
    "message": "Access denied by the integration",
    "details": "403 Forbidden - insufficient_permissions",
    "provider": "bynder"
  }
}

Rate limit

The exchange endpoint is rate-limited per partner, keyed by your iss. The default is 60 requests per minute; CI HUB can raise it for a partner on request. Other Access SDK endpoints are not rate-limited at this layer; they are bounded by the access token's one-hour lifetime instead.

Over the limit, the call returns 429 cihub-rate-limited. Every response carries the standard RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers, and a 429 adds Retry-After. Exchange once per session and cache the returned tokens rather than exchanging per request; on a 429, wait for Retry-After before retrying.

Email outside the JWT

CI HUB needs an email to resolve the user, but it does not have to travel in the JWT. Partners whose tokens carry no email claim, including those who anonymize the address, send it in the JSON request body instead:

const session = await client.exchangeToken({
  partnerJwt,
  emailFallback: 'u-8f21c4@anon.your-platform.example.com',
})
curl -X POST "https://stage.ci-hub.com/api/v1/auth/exchangeToken" \
  -H "Authorization: Bearer $PARTNER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"email": "u-8f21c4@anon.your-platform.example.com"}'
const response = await fetch(
  'https://stage.ci-hub.com/api/v1/auth/exchangeToken',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${partnerJwt}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email: 'u-8f21c4@anon.your-platform.example.com' }),
  }
)

A JWT email claim wins over the body, so send one or the other. Content-Type: application/json is required: no other content type is parsed for the field.

Two constraints apply to a pseudonymous address:

  • It must parse as an email. A bare opaque id is rejected with cihub-sdk-email-missing. A local part plus a domain you control is enough; the address does not have to receive mail.
  • It must be stable for the same person. The email is how CI HUB resolves a returning user. A value that changes between sessions creates a new CI HUB user each time, and each one takes a seat.

Names come from the JWT claims (given_name, family_name, name) only, never from the body.

Examples

The client library mints the partner JWT with its own server-only signer; the wire examples use the Node jsonwebtoken library. Any RS256-capable JWT library works; the contract is the JWT itself, not the language.

import { CiHubAccessClient, TokenManager } from '@ci-hub/access-sdk'
import { signPartnerJwt } from '@ci-hub/access-sdk/node'

// Server-side: the private key must never reach a browser.
const partnerJwt = await signPartnerJwt({
  privateKeyPem: process.env.PARTNER_PRIVATE_KEY!,
  keyId: 'your-kid',
  claims: {
    iss: 'https://auth.your-platform.example.com',
    aud: 'https://api.ci-hub.com',
    sub: 'user-12345',
    email: 'jane@customer.example.com',
  },
})

const client = new CiHubAccessClient({ baseUrl: 'https://stage.ci-hub.com/api/v1' })
const tokens = new TokenManager(client)

const session = await client.exchangeToken({ partnerJwt })
await tokens.setCihubSession(session)
PARTNER_JWT=$(node -e "
  const jwt = require('jsonwebtoken');
  const fs = require('fs');
  const privateKey = fs.readFileSync('./private.pem');
  const now = Math.floor(Date.now() / 1000);
  console.log(jwt.sign({
    iss: 'https://auth.your-platform.example.com',
    aud: 'https://api.ci-hub.com',
    sub: 'user-12345',
    email: 'jane@customer.example.com',
    iat: now,
    exp: now + 3600
  }, privateKey, { algorithm: 'RS256', keyid: 'your-kid' }));
")

curl -X POST "https://stage.ci-hub.com/api/v1/auth/exchangeToken" \
  -H "Authorization: Bearer $PARTNER_JWT" \
  -H "Content-Type: application/json" \
  -d '{}'
import fs from 'node:fs'
import jwt from 'jsonwebtoken'

const privateKey = fs.readFileSync('./private.pem')
const now = Math.floor(Date.now() / 1000)

const partnerJwt = jwt.sign(
  {
    iss: 'https://auth.your-platform.example.com',
    aud: 'https://api.ci-hub.com',
    sub: 'user-12345',
    email: 'jane@customer.example.com',
    iat: now,
    exp: now + 3600,
  },
  privateKey,
  { algorithm: 'RS256', keyid: 'your-kid' }
)

const response = await fetch(
  'https://stage.ci-hub.com/api/v1/auth/exchangeToken',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${partnerJwt}`,
      'Content-Type': 'application/json',
    },
    body: '{}',
  }
)

if (!response.ok) {
  const { error } = await response.json()
  throw new Error(`${error.code}: ${error.message}`)
}

const { access_token, refresh_token, expires_in } = await response.json()

Next

On this page