diff --git a/docs/connectors/login-token-auth.md b/docs/connectors/login-token-auth.md index c95083e88..8d25acf06 100644 --- a/docs/connectors/login-token-auth.md +++ b/docs/connectors/login-token-auth.md @@ -65,6 +65,11 @@ The token cache is keyed by `connectorId` (when available) or by `loginUrl|usern "loginUrl": "https://api.example.com/graphql", "loginMethod": "POST", // GET | POST (default POST) "loginHeaders": { "X-App": "anythingmcp" }, // static headers for the login call + "loginBasicAuth": true, // optional: send Authorization: Basic base64(username:password) + // on the login call only (Wazuh). The password is then not + // offered to templates, no body is sent without loginBody, and + // an Authorization in loginHeaders is refused, as are signedJwt + // and bcrypt passwordHashing. // ─── Body: prefer structured `loginBody` (recursive ${name} substitution) ─ "loginBody": { diff --git a/docs/tool-definition.md b/docs/tool-definition.md index 50e1156f8..937487ac1 100644 --- a/docs/tool-definition.md +++ b/docs/tool-definition.md @@ -1171,6 +1171,8 @@ The engine handles salt fetch → bcrypt → login → token cache → proactive } ``` +A login that takes HTTP Basic credentials (Wazuh's `POST /security/user/authenticate`) sets `"loginBasicAuth": true`: the engine sends `Authorization: Basic base64(username:password)` on the login request only (the API calls keep using `headerTemplate`), the password is never offered to the body templates (`${password}` renders empty), and no body is sent without a `loginBody`. An `Authorization` in `loginHeaders` is refused rather than overridden, as are `signedJwt`, bcrypt `passwordHashing` and a username containing `:`; `scripts/validate-adapters.mjs` reports the same mistakes (rule `login-basic-auth`). + See [`docs/connectors/login-token-auth.md`](connectors/login-token-auth.md) for the full field-by-field reference, salt-source types (`fetch` vs `static`), expiry formats (`iso8601` / `unix` / `ttl_seconds`), and re-login policies. --- diff --git a/llms-full.txt b/llms-full.txt index 6f79de88b..c317a80eb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1775,6 +1775,11 @@ The token cache is keyed by `connectorId` (when available) or by `loginUrl|usern "loginUrl": "https://api.example.com/graphql", "loginMethod": "POST", // GET | POST (default POST) "loginHeaders": { "X-App": "anythingmcp" }, // static headers for the login call + "loginBasicAuth": true, // optional: send Authorization: Basic base64(username:password) + // on the login call only (Wazuh). The password is then not + // offered to templates, no body is sent without loginBody, and + // an Authorization in loginHeaders is refused, as are signedJwt + // and bcrypt passwordHashing. // ─── Body: prefer structured `loginBody` (recursive ${name} substitution) ─ "loginBody": { @@ -4624,6 +4629,8 @@ The engine handles salt fetch → bcrypt → login → token cache → proactive } ``` +A login that takes HTTP Basic credentials (Wazuh's `POST /security/user/authenticate`) sets `"loginBasicAuth": true`: the engine sends `Authorization: Basic base64(username:password)` on the login request only (the API calls keep using `headerTemplate`), the password is never offered to the body templates (`${password}` renders empty), and no body is sent without a `loginBody`. An `Authorization` in `loginHeaders` is refused rather than overridden, as are `signedJwt`, bcrypt `passwordHashing` and a username containing `:`; `scripts/validate-adapters.mjs` reports the same mistakes (rule `login-basic-auth`). + See [`docs/connectors/login-token-auth.md`]() for the full field-by-field reference, salt-source types (`fetch` vs `static`), expiry formats (`iso8601` / `unix` / `ttl_seconds`), and re-login policies. --- diff --git a/packages/backend/src/adapters/catalog.spec.ts b/packages/backend/src/adapters/catalog.spec.ts index 4f7b35aaf..21c3be1d9 100644 --- a/packages/backend/src/adapters/catalog.spec.ts +++ b/packages/backend/src/adapters/catalog.spec.ts @@ -404,6 +404,18 @@ describe('adapter catalog', () => { expect(typeof cfg.username).toBe('string'); expect(typeof cfg.password).toBe('string'); } + if (cfg.loginBasicAuth !== undefined) { + // Same refusals as loginBasicAuthHeader(): the engine owns the + // login Authorization header and the password stays out of the body. + expect(typeof cfg.loginBasicAuth).toBe('boolean'); + if (cfg.loginBasicAuth) { + expect(cfg.signedJwt).toBeUndefined(); + expect((cfg.passwordHashing as { scheme?: string } | undefined)?.scheme ?? 'none').toBe('none'); + const headers = Object.keys((cfg.loginHeaders as Record) ?? {}); + expect(headers.map((h) => h.toLowerCase())).not.toContain('authorization'); + expect(JSON.stringify([cfg.loginBody ?? null, cfg.loginBodyTemplate ?? null, cfg.loginHeaders ?? null])).not.toMatch(/\$\{password(Hashed)?\}/); + } + } if (cfg.passwordHashing) { const ph = cfg.passwordHashing as Record; expect(VALID_PASSWORD_HASHING_SCHEMES.has(String(ph.scheme))).toBe(true); diff --git a/packages/backend/src/connectors/engines/login-token.service.spec.ts b/packages/backend/src/connectors/engines/login-token.service.spec.ts index d76c2a57f..0ebb96db0 100644 --- a/packages/backend/src/connectors/engines/login-token.service.spec.ts +++ b/packages/backend/src/connectors/engines/login-token.service.spec.ts @@ -1,4 +1,6 @@ +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { inspect } from 'util'; import axios from 'axios'; import * as bcrypt from 'bcrypt'; import { HttpsProxyAgent } from 'https-proxy-agent'; @@ -12,6 +14,7 @@ import { LoginTokenAuthConfig, proactiveRefreshWindowMs, signLoginJwt, + loginBasicAuthHeader, } from './login-token.service'; import { generateKeyPairSync, verify } from 'crypto'; import { injectLoginTokenHeaders } from './rest.engine'; @@ -834,6 +837,164 @@ describe('LoginTokenService — signedJwt login (noon Partner API pattern)', () }); }); +/** + * loginBasicAuth (#982, Wazuh): the login request carries + * `Authorization: Basic base64(username:password)`, built by the engine and + * never through a template. An Authorization in loginHeaders is a + * configuration error, refused before any request, so there is no doubt about + * which credentials the vendor sees. + */ +describe('LoginTokenService - loginBasicAuth (Wazuh pattern)', () => { + let service: LoginTokenService; + const encryptionKey = 'test-encryption-key-32-chars-ok!'; + // Built at runtime so no literal looks like a real credential. + const PASSWORD = ['pw', 'x'.repeat(8), ':*'].join(''); + const ENCODED = Buffer.from(`api-reader:${PASSWORD}`).toString('base64'); + + const wazuhAuth: LoginTokenAuthConfig = { + loginUrl: 'https://wazuh.example.com:55000/security/user/authenticate', + loginMethod: 'POST', + loginHeaders: { Accept: 'application/json' }, + loginBody: {}, + loginBasicAuth: true, + username: 'api-reader', + password: PASSWORD, + tokenJsonPath: 'data.token', + tokenTTLSeconds: 900, + }; + const axiosMock = () => mockedAxios as unknown as jest.Mock; + const logSpies = () => + (['log', 'debug', 'warn', 'error', 'verbose'] as const).map((m) => + jest.spyOn(Logger.prototype, m).mockImplementation(() => undefined), + ); + const logged = (spies: jest.SpyInstance[]) => + spies.flatMap((s) => s.mock.calls.map((c) => c.map((x: unknown) => String(x)).join(' '))).join('\n'); + + beforeEach(() => { + const mockPrisma = { + connectorAuthCache: { + findUnique: jest.fn().mockResolvedValue(null), + upsert: jest.fn().mockResolvedValue({}), + }, + } as any; + service = new LoginTokenService(mockPrisma, { get: jest.fn().mockReturnValue(encryptionKey) } as any); + jest.clearAllMocks(); + }); + afterEach(() => { + jest.restoreAllMocks(); + axiosMock().mockReset(); + }); + + it('sends Authorization: Basic base64(username:password) on the login, and the token as Bearer afterwards', async () => { + axiosMock().mockResolvedValue({ data: { data: { token: 'jwt-1' } }, headers: {} }); + const bundle = await service.getToken(wazuhAuth, 'conn-wazuh'); + const call = axiosMock().mock.calls[0][0]; + expect(call.method).toBe('POST'); + expect(call.headers.Authorization).toBe(`Basic ${ENCODED}`); + expect(call.headers.Accept).toBe('application/json'); + expect(call.data).toEqual({}); + expect(call.params).toBeUndefined(); + expect(bundle.token).toBe('jwt-1'); + const request: any = { headers: {} }; + injectLoginTokenHeaders(request, wazuhAuth, bundle.token, bundle.aud); + expect(request.headers.Authorization).toBe('Bearer jwt-1'); + }); + + it('never offers the password to templates, and sends no body without a loginBody', async () => { + axiosMock().mockResolvedValue({ data: { data: { token: 'jwt-2' } }, headers: {} }); + await service.getToken({ ...wazuhAuth, loginBody: { user: '${username}', pass: '${password}', h: '${passwordHashed}' } }, 'conn-wazuh-tpl'); + expect(axiosMock().mock.calls[0][0].data).toEqual({ user: 'api-reader', pass: '', h: '' }); + + const { loginBody: _omit, ...noBody } = wazuhAuth; + await service.getToken(noBody as LoginTokenAuthConfig, 'conn-wazuh-nobody'); + const call = axiosMock().mock.calls[1][0]; + expect(call.data).toBeUndefined(); + expect(call.params).toBeUndefined(); + expect(call.headers.Authorization).toBe(`Basic ${ENCODED}`); + }); + + it('refuses an explicit Authorization in loginHeaders (any case) before any request', async () => { + for (const name of ['Authorization', 'authorization', 'AUTHORIZATION']) { + const cfg = { ...wazuhAuth, loginHeaders: { [name]: 'Basic something-else' } }; + const err = await service.forceRelogin(cfg, `conn-${name}`).catch((e: Error) => e); + expect((err as Error).message).toBe( + 'LOGIN_TOKEN: loginBasicAuth sets the login Authorization header itself; remove Authorization from loginHeaders', + ); + expect(inspect(err)).not.toContain(PASSWORD); + } + expect(axiosMock()).not.toHaveBeenCalled(); + // Without the flag an explicit Authorization header is sent as before. + axiosMock().mockResolvedValue({ data: { data: { token: 'jwt-3' } }, headers: {} }); + const { loginBasicAuth: _f, ...plain } = wazuhAuth; + await service.getToken({ ...plain, loginHeaders: { Authorization: 'Token abc' } }, 'conn-plain'); + expect(axiosMock().mock.calls[0][0].headers.Authorization).toBe('Token abc'); + }); + + it('keeps the credentials out of a failed login error and out of the logs', async () => { + const spies = logSpies(); + const header = `Basic ${ENCODED}`; + axiosMock().mockImplementation(async (cfg: any) => { + const err: any = new Error(`Request failed with status code 401 (${header})`); + err.isAxiosError = true; + err.config = { ...cfg, headers: { ...cfg.headers } }; + err.request = { _header: `POST /security/user/authenticate HTTP/1.1\r\nAuthorization: ${header}\r\nHost: x\r\n\r\n` }; + err.response = { status: 401, data: { title: 'Unauthorized' }, headers: {}, config: { headers: { authorization: header } } }; + throw err; + }); + const err: any = await service.getToken(wazuhAuth, 'conn-wazuh-401').catch((e) => e); + expect(err.response.status).toBe(401); + for (const text of [err.message, inspect(err, { depth: 6 }), JSON.stringify({ c: err.config, r: err.response, q: err.request }), logged(spies)]) { + expect(text).not.toContain(PASSWORD); + expect(text).not.toContain(ENCODED); + } + expect(err.config.headers.Accept).toBe('application/json'); + }); + + it('keeps the credentials out of the proactive re-login warning', async () => { + const spies = logSpies(); + axiosMock().mockResolvedValueOnce({ data: { data: { token: 'jwt-old' } }, headers: {} }); + // 900 s token: 800 s later it is inside the refresh window but still valid. + const cfg = { ...wazuhAuth, proactiveRefreshSeconds: 120, tokenTTLSeconds: 900 }; + await service.getToken(cfg, 'conn-wazuh-warn'); + const later = Date.now() + 800_000; + jest.spyOn(Date, 'now').mockReturnValue(later); + axiosMock().mockRejectedValueOnce(Object.assign(new Error(`boom ${PASSWORD} ${ENCODED}`), { config: { headers: { Authorization: 'x' } } })); + const bundle = await service.getToken(cfg, 'conn-wazuh-warn'); + expect(bundle.token).toBe('jwt-old'); + const text = logged(spies); + expect(text).toContain('proactive re-login failed'); + expect(text).not.toContain(PASSWORD); + expect(text).not.toContain(ENCODED); + }); + + it('refuses combinations it cannot honour, without quoting credentials', () => { + const cases: Array<[Partial & Record, RegExp]> = [ + [{ loginBasicAuth: 'true' as unknown as boolean }, /loginBasicAuth must be true or false/], + [{ signedJwt: { privateKey: 'k' } }, /cannot be combined with signedJwt/], + [{ passwordHashing: { scheme: 'bcrypt', saltSource: { type: 'static', value: 's' } } }, /cannot be combined with passwordHashing/], + [{ username: 'a:b' }, /username must not contain ":"/], + [{ password: '' }, /authConfig\.password is required/], + ]; + for (const [patch, re] of cases) { + let message = ''; + try { + loginBasicAuthHeader({ ...wazuhAuth, ...patch } as LoginTokenAuthConfig); + } catch (e) { + message = (e as Error).message; + } + expect(message).toMatch(re); + expect(message).not.toContain(PASSWORD); + } + expect(loginBasicAuthHeader({ ...wazuhAuth, passwordHashing: { scheme: 'none' } })?.header).toBe(`Basic ${ENCODED}`); + expect(loginBasicAuthHeader({ ...wazuhAuth, loginBasicAuth: false })).toBeUndefined(); + }); + + it('encodes non-ASCII credentials as UTF-8', () => { + const h = loginBasicAuthHeader({ ...wazuhAuth, username: 'jos\u00e9', password: 'p\u00e4ss' })!.header; + expect(Buffer.from(h.replace(/^Basic /, ''), 'base64').toString('utf8')).toBe('jos\u00e9:p\u00e4ss'); + }); +}); + /** * A login that is refused answers 200 with the reason in the body. Reporting * "token not found at " describes our own configuration and diff --git a/packages/backend/src/connectors/engines/login-token.service.ts b/packages/backend/src/connectors/engines/login-token.service.ts index 0ee6128b9..6e9a0fdd4 100644 --- a/packages/backend/src/connectors/engines/login-token.service.ts +++ b/packages/backend/src/connectors/engines/login-token.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; +import axios, { type AxiosResponse } from 'axios'; import { randomUUID } from 'crypto'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../common/prisma.service'; @@ -74,6 +74,16 @@ export interface LoginTokenAuthConfig { // …or a raw string template (deprecated, brittle for GraphQL mutations). loginBodyTemplate?: string; loginHeaders?: Record; + /** + * Send `Authorization: Basic base64(username:password)` on the login request + * only, for APIs whose login takes HTTP Basic credentials and answers with a + * token (Wazuh). The engine builds the header itself: the password is not + * offered to loginBody/loginBodyTemplate (`${password}` renders empty) and, + * without a loginBody, the login has no body. Refused, before any request, + * when loginHeaders already set Authorization (any case), with signedJwt, + * with bcrypt passwordHashing, and for a username containing ":" (RFC 7617). + */ + loginBasicAuth?: boolean; // Required, unless the login proves who it is with `signedJwt` instead. username: string; password: string; @@ -305,9 +315,13 @@ export class LoginTokenService { this.requireString(authConfig.tokenJsonPath, 'tokenJsonPath'); } + const basicAuth = loginBasicAuthHeader(authConfig); + const passwordToSend = authConfig.signedJwt && authConfig.password === undefined ? '' - : await this.preparePassword(authConfig, request); + : basicAuth + ? '' + : await this.preparePassword(authConfig, request); const signedToken = authConfig.signedJwt ? signLoginJwt(authConfig.signedJwt) : undefined; @@ -331,6 +345,7 @@ export class LoginTokenService { const headers: Record = { 'Content-Type': 'application/json', ...(authConfig.loginHeaders || {}), + ...(basicAuth ? { Authorization: basicAuth.header } : {}), }; let data: unknown; @@ -348,26 +363,35 @@ export class LoginTokenService { `LOGIN_TOKEN loginBodyTemplate produced invalid JSON: ${e.message}`, ); } + } else if (basicAuth) { + // The credentials travel in the Authorization header only. + data = undefined; } else { data = templateParams; } this.logger.debug( - `LOGIN_TOKEN: logging in via ${method} ${urlForLog(url, { secrets: [authConfig.password, passwordToSend, signedToken ?? ''] })}${request.proxyUrl ? ' (via proxy)' : ''}`, + `LOGIN_TOKEN: logging in via ${method} ${urlForLog(url, { secrets: [authConfig.password, passwordToSend, signedToken ?? '', ...(basicAuth?.secrets ?? [])] })}${request.proxyUrl ? ' (via proxy)' : ''}`, ); - const response = await axios({ - method, - url, - data: method === 'GET' ? undefined : data, - params: method === 'GET' ? data : undefined, - headers, - timeout: 15000, - ...outboundAxiosOptions({ - credentialHeaders: Object.keys(authConfig.loginHeaders || {}), - credentialsInBody: true, - }), - ...proxyAgents(request), - }); + let response: AxiosResponse; + try { + response = await axios({ + method, + url, + data: method === 'GET' ? undefined : data, + params: method === 'GET' ? data : undefined, + headers, + timeout: 15000, + ...outboundAxiosOptions({ + credentialHeaders: Object.keys(authConfig.loginHeaders || {}), + credentialsInBody: true, + }), + ...proxyAgents(request), + }); + } catch (err) { + if (basicAuth) scrubLoginError(err, basicAuth.secrets); + throw err; + } let token: unknown; if (authConfig.tokenSource === 'cookie' && authConfig.cookieMode === 'all') { @@ -600,6 +624,83 @@ export class LoginTokenService { } } +/** + * The login's `Authorization: Basic` header when `loginBasicAuth` is true, + * else undefined. Built here from username and password, never through a + * template. Explicit configuration conflicts are refused instead of resolved + * silently: an Authorization in loginHeaders (which one would the vendor see?), + * signedJwt (no password), bcrypt passwordHashing (Basic sends the password + * as is) and a username with ":" (RFC 7617 cannot encode it). No error quotes + * a credential. `secrets` lists the strings to keep out of logs and errors. + */ +export function loginBasicAuthHeader( + cfg: LoginTokenAuthConfig, +): { header: string; secrets: string[] } | undefined { + const flag = (cfg as { loginBasicAuth?: unknown }).loginBasicAuth; + if (flag === undefined || flag === false) return undefined; + if (flag !== true) { + throw new Error('LOGIN_TOKEN: loginBasicAuth must be true or false'); + } + if (cfg.signedJwt) { + throw new Error('LOGIN_TOKEN: loginBasicAuth cannot be combined with signedJwt'); + } + if (cfg.passwordHashing && cfg.passwordHashing.scheme !== 'none') { + throw new Error( + 'LOGIN_TOKEN: loginBasicAuth sends the password as is and cannot be combined with passwordHashing', + ); + } + if (Object.keys(cfg.loginHeaders || {}).some((h) => h.toLowerCase() === 'authorization')) { + throw new Error( + 'LOGIN_TOKEN: loginBasicAuth sets the login Authorization header itself; remove Authorization from loginHeaders', + ); + } + for (const name of ['username', 'password'] as const) { + const v = cfg[name]; + if (!v || typeof v !== 'string') { + throw new Error(`LOGIN_TOKEN: authConfig.${name} is required`); + } + } + if (cfg.username.includes(':')) { + throw new Error('LOGIN_TOKEN: loginBasicAuth: the username must not contain ":"'); + } + const encoded = Buffer.from(`${cfg.username}:${cfg.password}`, 'utf8').toString('base64'); + return { header: `Basic ${encoded}`, secrets: [cfg.password, encoded] }; +} + +/** + * Remove the Basic credentials from a failed login's error before it travels + * on: the Authorization header from the request config axios attaches (also + * under response.config), the raw request head Node keeps on the request, and + * any occurrence of a secret in the message. Status and response stay, so the + * callers still see a 401 as a 401. + */ +function scrubLoginError(err: unknown, secrets: string[]): void { + if (!err || typeof err !== 'object') return; + const e = err as { + message?: unknown; + config?: { headers?: unknown }; + response?: { config?: { headers?: unknown } }; + request?: { _header?: unknown }; + }; + for (const holder of [e.config, e.response?.config]) { + const headers = holder?.headers as Record | undefined; + if (!headers || typeof headers !== 'object') continue; + const del = (headers as { delete?: unknown }).delete; + if (typeof del === 'function') del.call(headers, 'Authorization'); + for (const k of Object.keys(headers)) { + if (k.toLowerCase() === 'authorization') delete headers[k]; + } + } + if (e.request && typeof e.request === 'object' && typeof e.request._header === 'string') { + e.request._header = e.request._header.replace(/^authorization:.*$/gim, 'Authorization: [redacted]'); + } + if (typeof e.message === 'string') { + let m = e.message; + for (const secret of secrets) if (secret) m = m.split(secret).join('[redacted]'); + e.message = m; + } +} + /** * Resolve a dotted/bracket JSON path against a value. * Supports a.b.c and a.b[0].c. diff --git a/scripts/validate-adapters.mjs b/scripts/validate-adapters.mjs index 08b0f3fc7..aac67425d 100644 --- a/scripts/validate-adapters.mjs +++ b/scripts/validate-adapters.mjs @@ -112,6 +112,33 @@ function isPlaceholderReferenced(envVar, adapter) { return !!renewal && [renewal.appIdVar, renewal.appSecretVar, renewal.pageIdVar].includes(envVar); } +/** + * LOGIN_TOKEN logins that take HTTP Basic credentials (Wazuh) set + * loginBasicAuth: true. LoginTokenService builds the header itself and refuses + * the same combinations, so a bad adapter fails here instead of at sign-in. + */ +export function loginBasicAuthProblems(connector) { + const cfg = connector?.authConfig; + if (!cfg || typeof cfg !== 'object' || cfg.loginBasicAuth === undefined) return []; + if (connector.authType !== 'LOGIN_TOKEN') return ['loginBasicAuth only applies to LOGIN_TOKEN authentication']; + if (typeof cfg.loginBasicAuth !== 'boolean') return ['loginBasicAuth must be true or false']; + if (!cfg.loginBasicAuth) return []; + const problems = []; + for (const field of ['username', 'password']) { + if (typeof cfg[field] !== 'string' || !cfg[field].trim()) problems.push(`loginBasicAuth needs authConfig.${field}`); + } + if (cfg.signedJwt !== undefined) problems.push('loginBasicAuth cannot be combined with signedJwt'); + if (cfg.passwordHashing && cfg.passwordHashing.scheme !== 'none') problems.push('loginBasicAuth sends the password as is and cannot be combined with passwordHashing'); + const headers = cfg.loginHeaders && typeof cfg.loginHeaders === 'object' ? Object.keys(cfg.loginHeaders) : []; + if (headers.some((h) => h.toLowerCase() === 'authorization')) problems.push('loginBasicAuth sets the login Authorization header itself; remove Authorization from loginHeaders'); + const outputParam = cfg.passwordHashing?.outputParam || 'passwordHashed'; + const body = JSON.stringify([cfg.loginBody ?? null, cfg.loginBodyTemplate ?? null, cfg.loginHeaders ?? null]); + for (const name of new Set(['password', 'passwordHashed', outputParam])) { + if (body.includes(`${'$'}{${name}}`)) problems.push(`loginBasicAuth sends the password in the header only; remove ${'$'}{${name}} from the login body and headers`); + } + return problems; +} + // Leftover marker from `scripts/adapter-new.mjs`. Case-sensitive on purpose: // real adapters mention "to-do" lists, and only the uppercase word is ours. const TODO_MARKER = /\bTODO\b/; @@ -512,6 +539,10 @@ export function validateAdapter(adapter, file, region) { } } + for (const problem of loginBasicAuthProblems(adapter.connector)) { + errors.push(error('login-basic-auth', 'connector.authConfig.loginBasicAuth', problem, 'With loginBasicAuth: true the engine sends Authorization: Basic base64(username:password) on the login request; give username and password, and keep the password out of loginBody and loginHeaders.', 'authentication')); + } + if (adapter.checkBeforeAuthorization !== undefined) { const check = adapter.checkBeforeAuthorization; const okPath = check && typeof check === 'object' && typeof check.path === 'string' && check.path.trim() && !/^https?:/i.test(check.path); diff --git a/scripts/validate-adapters.test.mjs b/scripts/validate-adapters.test.mjs index 27df89095..06668fc7f 100644 --- a/scripts/validate-adapters.test.mjs +++ b/scripts/validate-adapters.test.mjs @@ -207,6 +207,35 @@ test('diagnostic docs use ordinary GitHub heading slugs', () => { assert.doesNotMatch(docs, /\{#[^}]+\}/); }); +test('loginBasicAuth needs credentials and keeps the password out of the login body and headers', () => { + const login = (authConfig, authType = 'LOGIN_TOKEN') => validateAdapter(adapter({ + connector: { type: 'REST', authType, authConfig: { + loginUrl: 'https://good.test/login', loginMethod: 'POST', tokenJsonPath: 'data.token', + username: '{{GOOD_USER}}', password: '{{GOOD_KEY}}', loginBasicAuth: true, loginBody: {}, ...authConfig, + } }, + requiredEnvVars: ['GOOD_USER', 'GOOD_KEY'], + }), 'good.json', 'de').errors.filter((e) => e.rule === 'login-basic-auth'); + + assert.deepEqual(login({}), []); + assert.deepEqual(login({ loginBasicAuth: false, loginBody: { p: '${password}' } }), []); + for (const [config, expected] of [ + [{ username: undefined }, /needs authConfig\.username/], + [{ password: '' }, /needs authConfig\.password/], + [{ loginBody: { pass: '${password}' } }, /remove \$\{password\}/], + [{ loginBodyTemplate: '{"p":"${passwordHashed}"}' }, /remove \$\{passwordHashed\}/], + [{ loginHeaders: { authorization: 'Basic x' } }, /remove Authorization from loginHeaders/], + [{ signedJwt: { privateKey: '{{GOOD_KEY}}' } }, /signedJwt/], + [{ passwordHashing: { scheme: 'bcrypt' } }, /passwordHashing/], + [{ loginBasicAuth: 'yes' }, /true or false/], + ]) { + const errors = login(config); + assert.equal(errors.length, 1, JSON.stringify(config)); + assert.match(errors[0].message, expected); + assert.equal(errors[0].docs, 'authentication'); + } + assert.match(login({}, 'BASIC_AUTH')[0].message, /only applies to LOGIN_TOKEN/); +}); + test('DATABASE adapters may authenticate with a connection string', () => { const result = validateAdapter(adapter({ slug: 'postgres', icon: 'postgres', region: 'intl',