CI HUBCI HUB SDK
Building an Integration

Login Flow

The login handler authenticates the user and returns tokens + adapterData to CI HUB.

CI HUB calls it when the user clicks Connect in the plugin UI.

supportsNewLoginUrl

Declare whether CI HUB should use the unified login endpoint when starting a connection. Set this in your integration capabilities (returned from info).

The SDK default is false when omitted. Recommended: set supportsNewLoginUrl: true for new integrations.

ValueCI HUB login URL
true/api/v1/auth/login
false (default)/api/v1/auth/login/:provider
src/index.ts
capabilities: {
  category: CategoryEnum.DAM,
  supportsNewLoginUrl: true, 
  // ...
}

CI HUB redirect URI allowlist

Configure redirect URI allowlists at the DAM (or your identity provider) so these CI HUB origins are permitted — the same list used for Direct Access Helper:

https://app-aagcgsdkbdg.canva-apps.com
https://app-aagppabnsou.canva-apps.com

https://live.ci-hub.com
https://stage.ci-hub.com
https://dev.ci-hub.com

https://ci-hub.azurewebsites.net
https://ci-hub-test.azurewebsites.net
https://ci-hub-beta.azurewebsites.net

http://localhost:8080
https://localhost:8080

Include the full URL that matches the desired login URL, e.g. http://localhost:8080/api/v1/auth/login when supportsNewLoginUrl is true, or http://localhost:8080/api/v1/auth/login/:INTEGRATION_NAME when supportsNewLoginUrl is false.

sendAccessToken

sendAccessToken(error?, accessToken?, expiresIn?, refreshToken?, refreshExpiresIn?, adapterData?)
ParameterTypeDescription
errorstring | nullError message — if set, login fails and the plugin UI shows the message
accessTokenstringToken (or credential object) stored and passed to all future handlers via locals.authPayload
expiresInnumberToken lifetime in seconds
refreshTokenstringRefresh token for refreshToken handler
refreshExpiresInnumberRefresh token lifetime in seconds
adapterDataRecord<string, unknown>Persisted object available in every future handler call via locals.adapterData

Pass the same type config as defineIntegration to type adapterData and OAuth state helpers instead of using manual casts. See Auth helpers.

adapterData

The adapterData object is persisted across all future handler calls. Access it via locals.adapterData. Typical fields: resourceEndpoint, orgId, brandId, API base URLs — anything your handlers need that isn't the auth token itself.

State Management

OAuth flows require multiple redirects. State helpers persist data across them:

src/index.ts
const state = await getStateCodeAsync()

await setStateAdapterDataAsync(state, { serverUrl, verifier })

const { serverUrl } = await getStateAdapterDataAsync(state)
FunctionDescription
getStateCodeAsync()Generates a random state code
setStateAdapterDataAsync(state, data)Persists data keyed to the state code — survives redirects
getStateAdapterDataAsync(state)Retrieves the persisted data

render(template, data)

Renders an ECT template to show login forms to the user. The first argument is the template path without .ect extension. See Login Templates.

src/index.ts
return render(path.join(import.meta.dirname, 'select-serverurl'), {
  title: 'Select Portal',
  action: urlObject.toString(),
  serverUrl: defaultUrl,
  logo,
  contact
})

sendRedirectUri(url)

Redirects the user's browser to an external URL — typically an OAuth authorization endpoint.

src/index.ts
return sendRedirectUri(authUrl)

Error Handling

Pass an error string as the first argument to sendAccessToken. The plugin UI displays it to the user.

src/index.ts
return sendAccessToken('Invalid credentials')

Patterns

OAuth + server URL form

User selects a server URL via a rendered form, then is redirected to the OAuth provider. After authorization, the code is exchanged for tokens.

Template (select-serverurl.ect):

src/select-serverurl.ect
<% extend 'login-template.ect' %>

<p>Please enter the URL to your INTEGRATION_NAME portal.</p>
<br>
<form action="<%- @action %>" method="post">
  <div class="field">
    <label class="label">INTEGRATION_NAME portal URL</label>
    <div class="control">
      <input class="inputfield" type="url" name="serverUrl" value="<%= @serverUrl %>" required>
    </div>
  </div>
  <div class="field is-grouped">
    <div class="control">
      <button class="button is-link" type="submit">Login</button>
    </div>
  </div>
</form>

Handler:

src/index.ts
login: async (locals, requestData) => {
  const code = requestData.query.code
  const authError = requestData.query.error || requestData.query.error_description ? `${requestData.query.error_description || 'Auth error'} (${requestData.query.error || '???'})` : ''
  let serverUrl = ensureHttps(((requestData.body.serverUrl || requestData.query.serverUrl || '') as string).trim(), true, true)
  const canceled = requestData.body.canceled || requestData.query.canceled
  const state = requestData.body.state || requestData.query.state

  if (authError) {
    return sendAccessToken(authError)
  }

  if (canceled) {
    return sendAccessToken()
  }

  // First call: create state and redirect to re-invoke the login handler
  if (!state) {
    const urlObject = new URL(locals.endpointUrl)

    urlObject.searchParams.set('state', await getStateCodeAsync())
    urlObject.searchParams.set('serverUrl', serverUrl)

    return sendRedirectUri(urlObject.toString())
  }

  // No server URL yet — render the form
  const serverUrlError = serverUrl && !await checkIsPortal(serverUrl)

  if (!serverUrl && !code || serverUrlError) {
    const urlObject = new URL(locals.endpointUrl)

    urlObject.searchParams.set('state', state)

    return render(path.join(import.meta.dirname, 'select-serverurl'), {
      title: 'Select INTEGRATION_NAME Portal',
      action: urlObject.toString(),
      serverUrl: serverUrl || config.get('IntegrationName.defaultServerUrl'),
      logo,
      contact,
      error: serverUrlError ? 'Invalid Url (System is not a valid INTEGRATION_NAME system).' : undefined
    })
  }

  // Retrieve serverUrl from state
  if (!serverUrl) {
    serverUrl = (await getStateAdapterDataAsync(state)).serverUrl
  }

  try {
    const oauth = getAuthClient(serverUrl)

    // No code yet — save serverUrl in state and redirect to OAuth provider
    if (!code) {
      await setStateAdapterDataAsync(state, {
        serverUrl
      })

      const authUrl = oauth.authorizeURL({
        redirect_uri: `${locals.endpointUrl}`,
        scope: SCOPES,
        state
      })

      return sendRedirectUri(authUrl)
    }

    // Code received — exchange for tokens
    const { token } = await oauth.getToken({
      code,
      redirect_uri: locals.endpointUrl
    })

    return sendAccessToken(null, token.access_token, token.expires_in, token.refresh_token, undefined, { resourceEndpoint: serverUrl })
  } catch (err) {
    return sendAccessToken(err)
  }
},

If your system does not send the expires_in for access_token and refresh_expires_in for refresh_token, you can hardcode them. It is not recommended but it is valid.

API key / credentials

User enters credentials via a rendered form. Credentials are validated against the platform API before completing login. The credential object itself is stored as the access token.

Template (login.ect):

src/login.ect
<% extend 'login-template.ect' %>

<p>Please enter your credentials.</p>
<br>
<form action="<%- @action %>" method="post">
<div class="field">
    <label class="label">Cloud Name</label>
    <div class="control">
      <input class="inputfield" type="text" name="cloudName" value="<%= @cloudName %>" required>
    </div>
  </div>
  <div class="field">
    <label class="label">API-Key</label>
    <div class="control">
      <input class="inputfield" type="text" name="apiKey" value="<%= @apiKey %>" required>
    </div>
  </div>
  <div class="field">
    <label class="label">API-Secret</label>
    <div class="control">
      <input class="inputfield" type="password" name="apiSecret" value="<%= @apiSecret %>" required>
    </div>
  </div>
  <div class="field is-grouped">
    <div class="control">
      <button class="button is-link" type="submit">Login</button>
    </div>
  </div>
</form>

Handler:

src/index.ts
login: async (locals, requestData) => {
  const endpointUrlWithParam = param => {
    const urlObject = new URL(locals.endpointUrl)

    Object.entries(param).forEach(([ key, value ]) => {
      urlObject.searchParams.set(key, value)
    })

    return urlObject.toString()
  }

  const { cloudName, apiKey, apiSecret } = requestData.body
  const token = {
    cloudName,
    apiKey,
    apiSecret
  }
  const adapterData = { resourceEndpoint: 'https://your-platform.example.com/api' }

  const state = requestData.body.state || requestData.query.state
  const canceled = (requestData.body.canceled || requestData.query.canceled) === 'true'

  try {
    // First call: create state
    if (!state) {
      return sendRedirectUri(endpointUrlWithParam({ state: await getStateCodeAsync() }))
    }

    // Render login form (reused for validation errors)
    const renderForm = error => render(path.join(import.meta.dirname, 'login'),
      Object.assign({
        title: 'INTEGRATION_NAME Login',
        action: endpointUrlWithParam({ state }),
        logo,
        providerName: 'INTEGRATION_NAME',
        error
      }, token))

    if (canceled) {
      return sendAccessToken('Login was cancelled')
    }

    // Incomplete credentials — show form
    if (!cloudName || !apiKey || !apiSecret) {
      return renderForm()
    }

    // Validate credentials against the platform API
    try {
      await DAMService.ping({
        cloud_name: cloudName,
        api_key: apiKey,
        api_secret: apiSecret
      })
    } catch (err) {
      if (err && err.error && err.error.message) {
        switch (err.error.message) {
          case 'cloud_name mismatch':
            return renderForm('INTEGRATION_NAME: Invalid Cloud Name')
          case 'unknown api_key':
            return renderForm('INTEGRATION_NAME: Invalid API-Key')
          case 'api_secret mismatch':
            return renderForm('INTEGRATION_NAME: Secret Mismatch')
          default:
            break
        }
      }

      return renderForm('INTEGRATION_NAME: Invalid Login Data')
    }

    // Credential object stored as the access token
    return sendAccessToken(null, token, null, null, null, adapterData)
  } catch (err) {
    return sendError(err, 400)
  }
},

OAuth PKCE + multi-step (server URL → OAuth → brand select)

Multi-step flow: user enters a server URL, gets redirected for PKCE-based OAuth, then selects a brand. State helpers persist data across each redirect.

src/index.ts
login: async (locals, requestData) => {
  const { code } = requestData.query
  const authError = requestData.query.error || requestData.query.error_description ? `${requestData.query.error_description || 'Auth error'} (${requestData.query.error || '???'})` : ''
  let serverUrl = ensureHttps(((requestData.body.serverUrl || requestData.query.serverUrl || '') as string).trim(), true, true)
  const canceled = requestData.body.canceled || requestData.query.canceled
  const state = requestData.body.state || requestData.query.state
  const brandId = requestData.body.brandId || requestData.query.brandId || ''

  if (authError) {
    return sendAccessToken(authError)
  }

  if (canceled) {
    return sendAccessToken()
  }

  // Step 1: create state and redirect
  if (!state) {
    const urlObject = new URL(locals.endpointUrl)

    urlObject.searchParams.set('state', await getStateCodeAsync())
    urlObject.searchParams.set('serverUrl', serverUrl)

    return sendRedirectUri(urlObject.toString())
  }

  // Step 2: no server URL yet — render the form
  if (!serverUrl && !code) {
    const urlObject = new URL(locals.endpointUrl)

    urlObject.searchParams.set('state', state as string)

    return render(path.join(import.meta.dirname, 'select-serverurl'), {
      title: 'Select INTEGRATION_NAME Portal',
      action: urlObject.toString(),
      serverUrl: serverUrl || config.get('IntegrationName.defaultServerUrl'),
      logo,
      contact
    })
  }

  if (!serverUrl) {
    ({ serverUrl } = await getStateAdapterDataAsync(state as string) as { serverUrl: string })
  }
  try {
    const oauth = getAuthClient(serverUrl)
    let tokenData = (await getStateAdapterDataAsync(state as string))?.tokenData as oauth2.Token

    // Step 3: no code yet — generate PKCE challenge and redirect to OAuth provider
    if (!code) {
      const verifier = createCodeVerifier()
      const challenge = createCodeChallenge(verifier)

      await setStateAdapterDataAsync(state as string, {
        serverUrl,
        verifier
      })

      const authUrl = oauth.authorizeURL({
        redirect_uri: `${locals.endpointUrl}`,
        code_challenge_method: 'S256',
        code_challenge: challenge,
        state: state as string,
        scope: SCOPES
      })

      return sendRedirectUri(authUrl)
    }

    // Step 4: code received — exchange with PKCE verifier
    if (!tokenData) {
      const { verifier } = await getStateAdapterDataAsync(state as string) as { verifier: string }
      const { token } = await oauth.getToken({
        code: code as string,
        client_id: 'your-client-id',
        redirect_uri: locals.endpointUrl,
        code_verifier: verifier
      })

      tokenData = token
    }

    // Step 5: no brand selected yet — fetch brands and render selection form
    if (!brandId) {
      await setStateAdapterDataAsync(state as string, {
        tokenData,
        serverUrl
      })
      const brands = await axios.post(`${serverUrl}/graphql`, {
        query: queries.getBrandsQuery
      }, {
        headers: {
          'Authorization': `Bearer ${tokenData.access_token}`,
          'Content-Type': 'application/json'
        }
      })
      const urlObject = new URL(locals.endpointUrl)

      urlObject.searchParams.set('state', state as string)
      urlObject.searchParams.set('serverUrl', serverUrl)
      urlObject.searchParams.set('code', code as string)

      return render(path.join(import.meta.dirname, 'select-serverurl'), {
        title: 'Select Brand',
        brands: brands.data.data.brands,
        authMethod: 'brands',
        serverUrl,
        state,
        action: urlObject.toString(),
        logo,
        contact
      })
    }

    // Step 6: complete — send tokens with brandId in adapterData
    return sendAccessToken(null, tokenData.access_token as string, tokenData.expires_in as number, tokenData.refresh_token as string, undefined, { resourceEndpoint: serverUrl, brandId })
  } catch (error: any) {
    return sendAccessToken(`Error logging in: ${error.message}`)
  }
},

Login Schema | Login Templates | Capabilities Reference

On this page