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?)| 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 |
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 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}`)
}
},