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.
| Value | CI HUB login URL |
|---|---|
true | /api/v1/auth/login |
false (default) | /api/v1/auth/login/:provider |
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:8080Include 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?)| Parameter | Type | Description |
|---|---|---|
error | string | null | Error message — if set, login fails and the plugin UI shows the message |
accessToken | string | Token (or credential object) stored and passed to all future handlers via locals.authPayload |
expiresIn | number | Token lifetime in seconds |
refreshToken | string | Refresh token for refreshToken handler |
refreshExpiresIn | number | Refresh token lifetime in seconds |
adapterData | Record<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:
const state = await getStateCodeAsync()
await setStateAdapterDataAsync(state, { serverUrl, verifier })
const { serverUrl } = await getStateAdapterDataAsync(state)| Function | Description |
|---|---|
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 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:
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):
<% 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: '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.
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}`)
}
},