Skip to content

Commit 4b9ba02

Browse files
authored
Add auth plugin to allow configurable auth strategy to CLI - finos#2453 (finos#2452)
* feat(cli): add initial auth plugin loader code * feat(cli): auth plugin work * feat(cli): make auth plugin work * feat(cli): use auth plugin in other calm commands * fix(shared): tests * feat(cli): tests * fix(cli): remove unused test auth plugin * fix(cli): improve linting and error logging * fix(cli): fix test fixtures * fix(cli): fix issue with clashing versions for docify * fix(cli): lockfile * fix(cli): proper tests for loading plugins * docs(cli): add docs for auth plugins * fix(cli): try removing dependency change * feat(cli): remove TS compilation of auth plugins
1 parent f13a0c4 commit 4b9ba02

16 files changed

Lines changed: 233 additions & 35 deletions

File tree

cli/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,26 @@ and
418418
419419
This differs:
420420
{{ block-architecture render-node-type-shapes=false }}
421+
```
422+
423+
## Authentication plugins
424+
425+
The CLI supports an external authentication plugin to allow authentication to CalmHub in enterprise environments, where seamless auth will likely require specific logic.
426+
427+
### Writing an authentication plugin
428+
429+
Authentication plugins are JavaScript files. You can find an example in [the test fixtures](./test_fixtures/test-auth-plugin.js).
430+
431+
Plugins must export a default class implementing the [`AuthPlugin`](../shared/src/auth/auth-plugin.ts) interface, i.e. a `getAuthHeaders(url, requestBody)` method that returns a `Promise<Record<string, string>>`.
432+
433+
The function `getAuthHeaders` will be invoked with the request URL and body for every request made to CalmHub by the CLI.
434+
435+
### Using an authentication plugin
436+
437+
To configure your CLI to use an auth plugin, use `~/.calm.json` in the same fashion as configuring a CalmHub URL:
438+
439+
```
440+
{
441+
"calmHubUrl": "http://calmhub.com",
442+
"authPluginPath": "~/plugins/auth-plugin.js"
443+
}

cli/src/cli-config.spec.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
import { fs, vol } from 'memfs';
2-
import { loadCliConfig } from './cli-config';
2+
import { loadCliConfig, loadAuthPlugin } from './cli-config';
3+
import { resolve } from 'path';
4+
import { homedir } from 'os';
5+
36

47
vi.mock('fs/promises', async () => {
58
const memfs: { fs: typeof fs } = await vi.importActual('memfs');
69

710
return memfs.fs.promises;
811
});
912

13+
vi.mock('fs', async () => {
14+
const memfs: { fs: typeof fs } = await vi.importActual('memfs');
15+
return memfs.fs;
16+
});
17+
1018
vi.mock('os', () => ({
11-
homedir: () => '/home/user'
19+
homedir: vi.fn(() => '/home/user')
1220
}));
1321

1422
const exampleConfig = {
1523
calmHubUrl: 'https://example.com/calmhub',
16-
allowedRemoteHosts: ['schemas.example.com']
24+
allowedRemoteHosts: ['schemas.example.com'],
25+
authPluginPath: './auth-plugin.js'
1726
};
1827

28+
const FIXTURES_DIR = resolve(__dirname, '../test_fixtures');
29+
const JS_FIXTURE = resolve(FIXTURES_DIR, 'test-auth-plugin.js');
1930

2031
describe('cli-config', () => {
2132
beforeEach(() => {
@@ -24,6 +35,7 @@ describe('cli-config', () => {
2435

2536
afterEach(() => {
2637
vol.reset();
38+
vi.mocked(homedir).mockReturnValue('/home/user');
2739
});
2840

2941
it('loads user config from .calm.json in home dir', async () => {
@@ -45,4 +57,48 @@ describe('cli-config', () => {
4557
});
4658
await expect(loadCliConfig()).resolves.toBeNull();
4759
});
60+
61+
it('replaces homedir in auth plugin path', async () => {
62+
vol.fromJSON({
63+
'/home/user/.calm.json': JSON.stringify({
64+
authPluginPath: '~/my-auth-plugin.js'
65+
})
66+
});
67+
68+
const config = await loadCliConfig();
69+
expect(config).toEqual({
70+
authPluginPath: '~/my-auth-plugin.js'
71+
});
72+
});
73+
74+
it('loads JavaScript auth plugin from absolute path', async () => {
75+
vol.fromJSON({
76+
'/home/user/.calm.json': JSON.stringify({ authPluginPath: JS_FIXTURE }),
77+
// just register this file exists. the actual loading mechanism, import(), will be handled by node which is mocked in the test environment to return a valid auth plugin.
78+
[JS_FIXTURE]: '',
79+
});
80+
81+
const config = await loadCliConfig();
82+
expect(config).toEqual({ authPluginPath: JS_FIXTURE });
83+
84+
const authPlugin = await loadAuthPlugin(config!.authPluginPath!, false);
85+
expect(authPlugin.getAuthHeaders).toBeDefined();
86+
});
87+
88+
it('loads JavaScript auth plugin with tilde path', async () => {
89+
// Point homedir at FIXTURES_DIR so ~/test-auth-plugin.js resolves to the real fixture file
90+
vi.mocked(homedir).mockReturnValue(FIXTURES_DIR);
91+
92+
vol.fromJSON({
93+
[resolve(FIXTURES_DIR, '.calm.json')]: JSON.stringify({ authPluginPath: '~/test-auth-plugin.js' }),
94+
[JS_FIXTURE]: '',
95+
});
96+
97+
const config = await loadCliConfig();
98+
expect(config).toEqual({ authPluginPath: '~/test-auth-plugin.js' });
99+
100+
const authPlugin = await loadAuthPlugin(config!.authPluginPath!, false);
101+
expect(authPlugin.getAuthHeaders).toBeDefined();
102+
});
103+
48104
});

cli/src/cli-config.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { initLogger } from '@finos/calm-shared';
21
import { readFile, writeFile } from 'fs/promises';
2+
import { initLogger, AuthPlugin } from '@finos/calm-shared';
3+
import { existsSync } from 'fs';
34
import { homedir } from 'os';
45
import { join } from 'path';
6+
import { pathToFileURL } from 'url';
57

68
export interface CLIConfig {
79
calmHubUrl?: string
810
allowedRemoteHosts?: string[]
11+
authPluginPath?: string
912
}
1013

1114
export function getUserConfigLocation(): string {
@@ -38,3 +41,43 @@ export async function loadCliConfig(): Promise<CLIConfig | null> {
3841
return null;
3942
}
4043
}
44+
45+
export function resolveHomeDir(path: string): string {
46+
if (path.startsWith('~')) {
47+
return join(homedir(), path.slice(1));
48+
}
49+
return path;
50+
}
51+
52+
export async function loadAuthPlugin(filename: string, debug: boolean): Promise<AuthPlugin> {
53+
const logger = initLogger(debug, 'auth-plugin');
54+
55+
filename = resolveHomeDir(filename);
56+
57+
if (!existsSync(filename)) {
58+
logger.error(`❌ Auth plugin file not found: ${filename}`);
59+
throw new Error(`❌ Auth plugin file not found: ${filename}`);
60+
}
61+
if (!filename.endsWith('.js')) {
62+
logger.error(`❌ Auth plugin file must have a .js extension: ${filename}`);
63+
throw new Error(`❌ Auth plugin file must have a .js extension: ${filename}`);
64+
}
65+
logger.info(`🔍 Loading auth plugin: ${filename}`);
66+
67+
try {
68+
const url = pathToFileURL(filename).href;
69+
const mod = await import(/* @vite-ignore */ url);
70+
const AuthPluginClass = mod.default;
71+
if (typeof AuthPluginClass !== 'function') {
72+
throw new Error('❌ Auth plugin must export a default class. Did you forget to export default?');
73+
}
74+
const instance = new AuthPluginClass() as AuthPlugin;
75+
if (typeof instance.getAuthHeaders !== 'function') {
76+
throw new Error('❌ Auth plugin class must implement getAuthHeaders(url, requestBody): Promise<Record<string, string>>');
77+
}
78+
return instance;
79+
} catch (error) {
80+
logger.error(`❌ Error loading auth plugin: ${error}`);
81+
throw new Error(`❌ Error loading auth plugin: ${error}`);
82+
}
83+
}

cli/src/cli.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,19 @@ export async function parseDocumentLoaderConfig(
293293
logger.info('Using CALMHub URL from config file: ' + userConfig.calmHubUrl);
294294
docLoaderOpts.calmHubUrl = userConfig.calmHubUrl;
295295
}
296+
297+
// if we have an auth plugin and we have calmHub configured
298+
if (userConfig && userConfig.authPluginPath) {
299+
logger.info('Loading auth plugin from config file: ' + userConfig.authPluginPath);
300+
try {
301+
const authPlugin = await cliConfig.loadAuthPlugin(userConfig.authPluginPath, !!options.verbose);
302+
docLoaderOpts.authPlugin = authPlugin;
303+
logger.debug('Auth plugin loaded successfully');
304+
} catch (err) {
305+
logger.error('Failed to load auth plugin: ' + (err instanceof Error ? err.message : String(err)));
306+
}
307+
}
308+
296309
if (userConfig && userConfig.allowedRemoteHosts && !options.allowedRemoteHosts) {
297310
logger.info('Using allowed remote hosts from config file');
298311
docLoaderOpts.allowedRemoteHosts = userConfig.allowedRemoteHosts;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default class TestAuthPlugin {
2+
async getAuthHeaders(url, requestBody) {
3+
console.log(`AUTH PLUGIN: Calling URL: ${url} with request body: ${JSON.stringify(requestBody)}`);
4+
return {
5+
'Test-Header': 'TestValue'
6+
};
7+
}
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { AuthPlugin } from '@finos/calm-shared'
2+
3+
export default class TestAuthPlugin implements AuthPlugin {
4+
async getAuthHeaders(url: string, requestBody: unknown): Promise<Record<string, string>> {
5+
console.log(`Generating auth headers for URL: ${url} with request body: ${JSON.stringify(requestBody)}`);
6+
return {
7+
'Test-Header': 'TestValue'
8+
};
9+
}
10+
}

conferences/osff-ln-2025/workshop/architecture/conference-signup.arch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,5 +132,5 @@
132132
}
133133
}
134134
],
135-
"$schema": "https://calm.finos.org/workshop/conference-signup.pattern.json"
135+
"$schema": "calm://calm.finos.org/calm/namespaces/workshop/patterns/1/versions/1.0.0"
136136
}

0 commit comments

Comments
 (0)