feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)
* feat(migration): Björn Lundén activation through Lundify's redirect flow BL issued our integration activation key on 2026-09-07. With BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i Lundify": the customer logs in at Lundify, picks the company and accepts the scopes, and Lundify returns the company's User-Key to our callback as publicKey with our one-time state echoed as extra. The manual User-Key field stays as a folded fallback for companies that activated inside Lundify already. The callback folds publicKey/extra into the OAuth-shaped locals, so the atomic state consumption, initiator binding and white-label handoff run unchanged; only the final step differs: submitProviderToken (the same client-credentials probe as the manual field) instead of an OAuth code exchange, owned by the consent's company read from the server-written row. consumeOAuthState/consumeHandoff now return that company id. Closes #2323. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L * fix(migration): reset the previous connect attempt before a new provider request Review follow-up: a failed /connect used to leave the earlier consent id and one-time activation URL in place, so the step kept offering a link that completed the previous consent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
8aa5d54510
commit
a769bc9d03
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LUNDIFY_ACTIVATION_BASE_URL,
|
||||
buildLundifyActivationUrl,
|
||||
getBjornLundenActivationKey,
|
||||
} from '../activation';
|
||||
|
||||
const ORIGINAL_KEY = process.env.BJORN_LUNDEN_ACTIVATION_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_KEY === undefined) {
|
||||
delete process.env.BJORN_LUNDEN_ACTIVATION_KEY;
|
||||
} else {
|
||||
process.env.BJORN_LUNDEN_ACTIVATION_KEY = ORIGINAL_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
describe('getBjornLundenActivationKey', () => {
|
||||
it('returns null when the env var is unset or blank', () => {
|
||||
delete process.env.BJORN_LUNDEN_ACTIVATION_KEY;
|
||||
expect(getBjornLundenActivationKey()).toBeNull();
|
||||
process.env.BJORN_LUNDEN_ACTIVATION_KEY = ' ';
|
||||
expect(getBjornLundenActivationKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the trimmed key when set', () => {
|
||||
process.env.BJORN_LUNDEN_ACTIVATION_KEY = ' 36b2bf61-0514-4825-a8a5-08cf151176f2 ';
|
||||
expect(getBjornLundenActivationKey()).toBe('36b2bf61-0514-4825-a8a5-08cf151176f2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLundifyActivationUrl', () => {
|
||||
const KEY = '36b2bf61-0514-4825-a8a5-08cf151176f2';
|
||||
const CALLBACK = 'https://app.accounted.se/api/extensions/ext/arcim-migration/callback';
|
||||
|
||||
it('follows BL: /activate-integration/{key}/{encodedRedirectUrl}?extra={state}', () => {
|
||||
const url = buildLundifyActivationUrl(KEY, CALLBACK, 'state-abc_123');
|
||||
expect(url).toBe(
|
||||
`${LUNDIFY_ACTIVATION_BASE_URL}/${KEY}/${encodeURIComponent(CALLBACK)}?extra=state-abc_123`,
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes the redirect URL as ONE path segment so its slashes do not split the path', () => {
|
||||
const url = new URL(buildLundifyActivationUrl(KEY, CALLBACK, 's'));
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
expect(segments).toEqual(['activate-integration', KEY, encodeURIComponent(CALLBACK)]);
|
||||
expect(decodeURIComponent(segments[2]!)).toBe(CALLBACK);
|
||||
});
|
||||
|
||||
it('percent-encodes a state that carries URL-significant characters', () => {
|
||||
const state = 'a+b&c=d/e';
|
||||
const url = new URL(buildLundifyActivationUrl(KEY, CALLBACK, state));
|
||||
expect(url.searchParams.get('extra')).toBe(state);
|
||||
expect(url.search).not.toContain('&c=');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Björn Lundén activation via Lundify's redirect flow.
|
||||
*
|
||||
* BL documents three ways a customer can activate an integration (Company
|
||||
* Activation & Key Retrieval, developer.bjornlunden.se/2025/03/31/activation-guide/).
|
||||
* The third one removes the GUID copy-paste from our connect step: we send the
|
||||
* user to Lundify with our integration activation key, they log in, pick the
|
||||
* company and accept the scopes, and Lundify sends them back to our callback
|
||||
* with the company's User-Key as `publicKey` and our opaque state as `extra`.
|
||||
*
|
||||
* https://lundify.com/activate-integration/{integrationActivationKey}/{encodedRedirectUrl}?extra={state}
|
||||
* -> {redirectUrl}?publicKey={userKey}&extra={state}
|
||||
*
|
||||
* The activation key is issued by BL once per service provider (ours arrived
|
||||
* 2026-09-07). It is not a secret in the credential sense: it is embedded in a
|
||||
* URL the customer's browser visits. It still lives in an env var so
|
||||
* self-hosted installs without a BL listing simply keep the manual User-Key
|
||||
* field.
|
||||
*/
|
||||
|
||||
export const LUNDIFY_ACTIVATION_BASE_URL = 'https://lundify.com/activate-integration';
|
||||
|
||||
/**
|
||||
* The activation key BL issued for this service provider, or null when the
|
||||
* redirect flow is not configured (the connect step then only offers the
|
||||
* manual User-Key field).
|
||||
*/
|
||||
export function getBjornLundenActivationKey(): string | null {
|
||||
const key = process.env.BJORN_LUNDEN_ACTIVATION_KEY?.trim();
|
||||
return key ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Lundify activation URL for one connect attempt.
|
||||
*
|
||||
* `redirectUrl` is our callback (the same one the OAuth providers use) and is
|
||||
* percent-encoded as a single path segment, which is what BL's
|
||||
* `{encodedRedirectUrl}` placeholder asks for: an unencoded URL would split on
|
||||
* its own slashes. `state` is the opaque one-time code minted for the consent;
|
||||
* Lundify echoes it back untouched as `extra`, so the callback can resolve the
|
||||
* consent from a server-written row instead of trusting anything in the query.
|
||||
*/
|
||||
export function buildLundifyActivationUrl(
|
||||
activationKey: string,
|
||||
redirectUrl: string,
|
||||
state: string,
|
||||
): string {
|
||||
const url = new URL(
|
||||
`${LUNDIFY_ACTIVATION_BASE_URL}/${encodeURIComponent(activationKey)}/${encodeURIComponent(redirectUrl)}`,
|
||||
);
|
||||
url.searchParams.set('extra', state);
|
||||
return url.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user