CI HUBCI HUB SDK
Building an Adapter

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.

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

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:

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.

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.

return sendRedirectUri(authUrl)

Error Handling

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

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):

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

<p>Please enter the URL to your ExampleAdapter1 portal.</p>
<br>
<form action="<%- @action %>" method="post">
  <div class="field">
    <label class="label">ExampleAdapter1 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:

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 checkIsExampleAdapter1Portal(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 ExampleAdapter1 Portal',
      action: urlObject.toString(),
      serverUrl: serverUrl || config.get('exampleAdapter1.defaultServerUrl'),
      logo,
      contact,
      error: serverUrlError ? 'Invalid Url (System is not a valid ExampleAdapter1 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):

<% 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:

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: 'ExampleAdapter2 Login',
        action: endpointUrlWithParam({ state }),
        logo,
        providerName: 'ExampleAdapter2',
        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('ExampleAdapter2: Invalid Cloud Name')
          case 'unknown api_key':
            return renderForm('ExampleAdapter2: Invalid API-Key')
          case 'api_secret mismatch':
            return renderForm('ExampleAdapter2: Secret Mismatch')
          default:
            break
        }
      }

      return renderForm('ExampleAdapter2: 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.

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 ExampleAdapter3 Portal',
      action: urlObject.toString(),
      serverUrl: serverUrl || config.get('exampleAdapter3.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

On this page