Skip to content

Commit c6965ba

Browse files
authored
feat #197: implement Bloomreach webapp authentication (browser login, session management, CLI/MCP/API interfaces) (#198)
- Add auth/ module directory with loginSelectors, apiAuth, webappApiClient, authManager - Add login page selectors with Bloomreach-specific data-e2e-id strategies and auto-fill - Add BloomreachApiAuth class for API credential resolution and verification - Add BloomreachWebappApiClient for session-cookie-based webapp API calls - Add BloomreachAuthManager unified coordinator with createAuthManager() factory - Enhance bloomreachAuth.ts with getSessionCookies(), logout(), auto-fill in openLogin() - Add deleteSession() to bloomreachSessionStore.ts - Add CLI commands: logout, auth status (unified), auth verify-api - Add 4 MCP tools: auth.status, auth.login, auth.logout, auth.verify_api - Update barrel exports and .env.example with BLOOMREACH_URL/EMAIL/PASSWORD - All quality gates pass: typecheck, lint, 9942 tests, build
1 parent 196e487 commit c6965ba

18 files changed

+1855
-16
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ BLOOMREACH_API_SECRET=your-api-secret
1616
# BLOOMREACH_ENVIRONMENT=my-env
1717
# BLOOMREACH_API_TOKEN=my-token
1818

19+
# Optional: Override the Bloomreach webapp URL (default: https://app.bloomreach.com/)
20+
# BLOOMREACH_URL=https://app.bloomreach.com/
21+
22+
# Optional: Auto-fill login credentials (used by "bloomreach login" auto-fill)
23+
# Credentials are only used for auto-fill — manual login is always available.
24+
# BLOOMREACH_EMAIL=your-email@example.com
25+
# BLOOMREACH_PASSWORD=your-password
26+
1927
# Optional: Browser authentication settings
2028
# Directory for Playwright persistent browser profiles (default: ~/.bloomreach-buddy/profiles)
2129
# BLOOMREACH_PROFILE_DIR=~/.bloomreach-buddy/profiles

packages/cli/src/bin/bloomreach.ts

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
openBrowserUrl,
5454
BloomreachProfileManager,
5555
BLOOMREACH_API_SETTINGS_URL,
56+
createAuthManager,
5657
} from '@bloomreach-buddy/core';
5758
import type {
5859
ActionExecutor,
@@ -12364,38 +12365,88 @@ program
1236412365
},
1236512366
);
1236612367

12368+
program
12369+
.command('logout')
12370+
.description('Clear stored Bloomreach browser session')
12371+
.option('--profile <name>', 'Browser profile name', 'default')
12372+
.option('--json', 'Output as JSON')
12373+
.action(
12374+
async (options: { profile: string; json?: boolean }) => {
12375+
try {
12376+
const authManager = createAuthManager();
12377+
const result = await authManager.logout({ profileName: options.profile });
12378+
12379+
if (options.json) {
12380+
console.log(JSON.stringify(result, null, 2));
12381+
} else if (result.cleared) {
12382+
console.log(` Session cleared for profile "${result.profileName}".`);
12383+
} else {
12384+
console.log(` No session found for profile "${result.profileName}".`);
12385+
}
12386+
} catch (error) {
12387+
if (options.json) {
12388+
console.log(
12389+
JSON.stringify(
12390+
{
12391+
cleared: false,
12392+
error: error instanceof Error ? error.message : String(error),
12393+
},
12394+
null,
12395+
2,
12396+
),
12397+
);
12398+
} else {
12399+
console.error(
12400+
`Error: ${error instanceof Error ? error.message : String(error)}`,
12401+
);
12402+
}
12403+
process.exit(1);
12404+
}
12405+
},
12406+
);
12407+
1236712408
const auth = program
1236812409
.command('auth')
1236912410
.description('Manage Bloomreach browser authentication');
1237012411

1237112412
auth
1237212413
.command('status')
12373-
.description('Check browser session authentication status')
12414+
.description('Check all authentication states (API credentials + browser session)')
1237412415
.option('--profile <name>', 'Browser profile name', 'default')
1237512416
.option('--json', 'Output as JSON')
1237612417
.action(
1237712418
async (options: { profile: string; json?: boolean }) => {
1237812419
try {
12379-
const profilesDir = resolveProfilesDir();
12380-
const profileManager = new BloomreachProfileManager({ profilesDir });
12381-
const authService = new BloomreachAuthService(profileManager, { profilesDir });
12382-
12383-
const status = await authService.status({ profileName: options.profile });
12420+
const authManager = createAuthManager();
12421+
const status = await authManager.status({ profileName: options.profile });
1238412422

1238512423
if (options.json) {
1238612424
console.log(JSON.stringify(status, null, 2));
1238712425
} else {
1238812426
console.log('');
12389-
console.log(` Authenticated: ${status.authenticated ? 'yes' : 'no'}`);
12390-
console.log(` Profile: ${status.profileName}`);
12391-
console.log(` Reason: ${status.reason}`);
12392-
if (status.sessionExpired) {
12393-
console.log(' Session: expired');
12427+
console.log(' Bloomreach Auth Status');
12428+
console.log(' =====================');
12429+
console.log('');
12430+
console.log(' API Credentials (Tier 1):');
12431+
console.log(` Configured: ${status.api.configured ? 'yes' : 'no'}`);
12432+
if (status.api.projectToken) console.log(` Token: ${status.api.projectToken}`);
12433+
if (status.api.apiKeyId) console.log(` Key ID: ${status.api.apiKeyId}`);
12434+
if (status.api.baseUrl) console.log(` Base URL: ${status.api.baseUrl}`);
12435+
console.log('');
12436+
console.log(' Browser Session (Tier 2/3):');
12437+
console.log(` Authenticated: ${status.browser.authenticated ? 'yes' : 'no'}`);
12438+
console.log(` Profile: ${status.browser.profileName}`);
12439+
console.log(` Reason: ${status.browser.reason}`);
12440+
if (status.browser.sessionExpired) {
12441+
console.log(' Session: expired');
1239412442
}
12395-
if (status.cookieSummary && status.cookieSummary.length > 0) {
12396-
console.log(` Cookies: ${status.cookieSummary.length} stored`);
12443+
if (status.browser.cookieSummary && status.browser.cookieSummary.length > 0) {
12444+
console.log(` Cookies: ${status.browser.cookieSummary.length} stored`);
1239712445
}
1239812446
console.log('');
12447+
console.log(' Webapp API (Tier 2):');
12448+
console.log(` Ready: ${status.webappApiReady ? 'yes' : 'no'}`);
12449+
console.log('');
1239912450
}
1240012451
} catch (error) {
1240112452
if (options.json) {
@@ -12486,4 +12537,62 @@ auth
1248612537
},
1248712538
);
1248812539

12540+
auth
12541+
.command('verify-api')
12542+
.description('Verify Bloomreach API credentials against the live API')
12543+
.option('--json', 'Output as JSON')
12544+
.action(
12545+
async (options: { json?: boolean }) => {
12546+
try {
12547+
const authManager = createAuthManager();
12548+
const result = await authManager.verifyApi();
12549+
12550+
if (options.json) {
12551+
console.log(JSON.stringify(result, null, 2));
12552+
} else {
12553+
console.log('');
12554+
console.log(' API Credential Verification');
12555+
console.log(' --------------------------');
12556+
console.log(` Configured: ${result.configured ? 'yes' : 'no'}`);
12557+
if (!result.configured) {
12558+
console.log(
12559+
' Set BLOOMREACH_PROJECT_TOKEN, BLOOMREACH_API_KEY_ID, and BLOOMREACH_API_SECRET.',
12560+
);
12561+
console.log('');
12562+
process.exit(1);
12563+
}
12564+
console.log(` Verified: ${result.verified ? 'yes' : 'no'}`);
12565+
if (result.projectToken) console.log(` Token: ${result.projectToken}`);
12566+
if (result.apiKeyId) console.log(` Key ID: ${result.apiKeyId}`);
12567+
if (result.baseUrl) console.log(` Base URL: ${result.baseUrl}`);
12568+
if (result.serverTime) {
12569+
console.log(` Server time: ${new Date(result.serverTime * 1000).toISOString()}`);
12570+
}
12571+
if (result.error && !result.verified) {
12572+
console.log(` Error: ${result.error}`);
12573+
}
12574+
console.log('');
12575+
if (!result.verified) process.exit(1);
12576+
}
12577+
} catch (error) {
12578+
if (options.json) {
12579+
console.log(
12580+
JSON.stringify(
12581+
{
12582+
configured: false,
12583+
verified: false,
12584+
error: error instanceof Error ? error.message : String(error),
12585+
},
12586+
null,
12587+
2,
12588+
),
12589+
);
12590+
} else {
12591+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
12592+
}
12593+
process.exit(1);
12594+
}
12595+
},
12596+
);
12597+
1248912598
program.parse();
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { BloomreachBuddyError } from '../../errors.js';
3+
import { BloomreachApiAuth, maskSecret } from '../../auth/apiAuth.js';
4+
import type { BloomreachApiConfig } from '../../bloomreachApiClient.js';
5+
import type { ValidateCredentialsResult } from '../../bloomreachSetup.js';
6+
7+
vi.mock('../../bloomreachApiClient.js', () => ({
8+
resolveApiConfig: vi.fn(),
9+
}));
10+
11+
vi.mock('../../bloomreachSetup.js', () => ({
12+
validateCredentials: vi.fn(),
13+
}));
14+
15+
import { resolveApiConfig } from '../../bloomreachApiClient.js';
16+
import { validateCredentials } from '../../bloomreachSetup.js';
17+
18+
const CONFIG: BloomreachApiConfig = {
19+
projectToken: 'projecttoken-12345',
20+
apiKeyId: 'apikeyid-67890',
21+
apiSecret: 'secret-00000',
22+
baseUrl: 'https://api.exponea.com',
23+
};
24+
25+
describe('maskSecret', () => {
26+
it('masks long values with default visible length', () => {
27+
expect(maskSecret('abcdefghijklmnop')).toBe('abcdefgh...');
28+
});
29+
30+
it('returns short values unchanged', () => {
31+
expect(maskSecret('short')).toBe('short');
32+
});
33+
34+
it('supports custom visible length', () => {
35+
expect(maskSecret('abcdef', 3)).toBe('abc...');
36+
});
37+
});
38+
39+
describe('BloomreachApiAuth', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
vi.useFakeTimers();
43+
vi.setSystemTime(new Date('2026-02-01T12:00:00.000Z'));
44+
});
45+
46+
afterEach(() => {
47+
vi.useRealTimers();
48+
});
49+
50+
it('constructor stores resolved config when credentials are present', () => {
51+
vi.mocked(resolveApiConfig).mockReturnValue(CONFIG);
52+
53+
const auth = new BloomreachApiAuth();
54+
55+
expect(resolveApiConfig).toHaveBeenCalledWith(undefined);
56+
expect(auth.isConfigured()).toBe(true);
57+
expect(auth.getConfig()).toEqual(CONFIG);
58+
});
59+
60+
it('constructor treats resolver errors as unconfigured state', () => {
61+
vi.mocked(resolveApiConfig).mockImplementation(() => {
62+
throw new BloomreachBuddyError('CONFIG_MISSING', 'Missing credentials');
63+
});
64+
65+
const auth = new BloomreachApiAuth();
66+
67+
expect(auth.isConfigured()).toBe(false);
68+
expect(() => auth.getConfig()).toThrow(BloomreachBuddyError);
69+
});
70+
71+
it('status returns configured details with masked secrets', () => {
72+
vi.mocked(resolveApiConfig).mockReturnValue(CONFIG);
73+
74+
const auth = new BloomreachApiAuth();
75+
const result = auth.status();
76+
77+
expect(result).toEqual({
78+
configured: true,
79+
verified: false,
80+
projectToken: 'projectt...',
81+
apiKeyId: 'apikeyid...',
82+
baseUrl: 'https://api.exponea.com',
83+
checkedAt: '2026-02-01T12:00:00.000Z',
84+
});
85+
});
86+
87+
it('status returns unconfigured details when config is missing', () => {
88+
vi.mocked(resolveApiConfig).mockImplementation(() => {
89+
throw new BloomreachBuddyError('CONFIG_MISSING', 'Missing credentials');
90+
});
91+
92+
const auth = new BloomreachApiAuth();
93+
const result = auth.status();
94+
95+
expect(result).toEqual({
96+
configured: false,
97+
verified: false,
98+
projectToken: undefined,
99+
apiKeyId: undefined,
100+
baseUrl: undefined,
101+
checkedAt: '2026-02-01T12:00:00.000Z',
102+
});
103+
});
104+
105+
it('verify returns mapped success result when credentials are valid', async () => {
106+
const validationResult: ValidateCredentialsResult = {
107+
valid: true,
108+
serverTime: 1_700_000_000,
109+
};
110+
vi.mocked(resolveApiConfig).mockReturnValue(CONFIG);
111+
vi.mocked(validateCredentials).mockResolvedValue(validationResult);
112+
113+
const auth = new BloomreachApiAuth();
114+
const result = await auth.verify();
115+
116+
expect(validateCredentials).toHaveBeenCalledWith(CONFIG);
117+
expect(result).toEqual({
118+
configured: true,
119+
verified: true,
120+
projectToken: 'projectt...',
121+
apiKeyId: 'apikeyid...',
122+
baseUrl: 'https://api.exponea.com',
123+
serverTime: 1_700_000_000,
124+
error: undefined,
125+
errorCategory: undefined,
126+
checkedAt: '2026-02-01T12:00:00.000Z',
127+
});
128+
});
129+
130+
it('verify returns mapped failure result when credentials are invalid', async () => {
131+
const validationResult: ValidateCredentialsResult = {
132+
valid: false,
133+
error: 'invalid_credentials',
134+
message: 'API key ID or secret is incorrect.',
135+
};
136+
vi.mocked(resolveApiConfig).mockReturnValue(CONFIG);
137+
vi.mocked(validateCredentials).mockResolvedValue(validationResult);
138+
139+
const auth = new BloomreachApiAuth();
140+
const result = await auth.verify();
141+
142+
expect(result.verified).toBe(false);
143+
expect(result.error).toBe('API key ID or secret is incorrect.');
144+
expect(result.errorCategory).toBe('invalid_credentials');
145+
expect(result.checkedAt).toBe('2026-02-01T12:00:00.000Z');
146+
});
147+
148+
it('verify returns unconfigured status without calling validateCredentials', async () => {
149+
vi.mocked(resolveApiConfig).mockImplementation(() => {
150+
throw new BloomreachBuddyError('CONFIG_MISSING', 'Missing credentials');
151+
});
152+
153+
const auth = new BloomreachApiAuth();
154+
const result = await auth.verify();
155+
156+
expect(validateCredentials).not.toHaveBeenCalled();
157+
expect(result.configured).toBe(false);
158+
expect(result.verified).toBe(false);
159+
});
160+
161+
it('getConfig throws CONFIG_MISSING when unconfigured', () => {
162+
vi.mocked(resolveApiConfig).mockImplementation(() => {
163+
throw new BloomreachBuddyError('CONFIG_MISSING', 'Missing credentials');
164+
});
165+
166+
const auth = new BloomreachApiAuth();
167+
168+
expect(() => auth.getConfig()).toThrow(
169+
expect.objectContaining({ code: 'CONFIG_MISSING' }),
170+
);
171+
});
172+
173+
it('isConfigured returns false when constructor failed to resolve config', () => {
174+
vi.mocked(resolveApiConfig).mockImplementation(() => {
175+
throw new BloomreachBuddyError('CONFIG_MISSING', 'Missing credentials');
176+
});
177+
178+
const auth = new BloomreachApiAuth();
179+
180+
expect(auth.isConfigured()).toBe(false);
181+
});
182+
});

0 commit comments

Comments
 (0)