auth-tenant-pool-plugins.test.ts429 lines · main
1/**
2 * Unit tests for the Phase 3 (Auth Product) per-tenant Better Auth wiring:
3 * - buildTenantAuthPlugins → loads magic-link + email-OTP plugins per config
4 * - buildAuthDatabaseHooks → fans lifecycle events to an (injected) dispatch
5 * - resetPasswordUrl → the hosted new-password reset-link contract
6 *
7 * Pure / dependency-injected on purpose — no real Better Auth, no postgres, no
8 * email — so these run without BRIVEN_DATA_PLANE_URL or any live DB.
9 */
10import { describe, expect, test } from 'bun:test';
11
12import {
13 APP_AUTH_PROXY_PREFIX,
14 assembleTenantPlugins,
15 buildAuthDatabaseHooks,
16 buildGenericOAuthConfigs,
17 buildTenantAuthPlugins,
18 hostedAuthBaseUrl,
19 registrableDomainFromHost,
20 resetPasswordUrl,
21 resolveAppOriginForAuthEmail,
22 resolvePasskeyRpId,
23 rewriteAuthActionUrlToApp,
24 type AuthEventDispatcher,
25} from './auth-tenant-pool.js';
26import {
27 DEFAULT_AUTH_CONFIG,
28 type AuthConfig,
29 type CustomOidcProvider,
30} from './tenant-config-store.js';
31
32const PROJECT_ID = 'p_test123';
33
34function configWith(overrides: Partial<AuthConfig['providers']>): AuthConfig {
35 return {
36 ...DEFAULT_AUTH_CONFIG,
37 providers: { ...DEFAULT_AUTH_CONFIG.providers, ...overrides },
38 };
39}
40
41/** Explicit all-passwordless-off (starter pack defaults are ON now). */
42function passwordlessOff(): AuthConfig {
43 return configWith({
44 magicLink: { enabled: false, expiryMinutes: 15 },
45 emailOtp: { enabled: false, codeLength: 6, expiryMinutes: 5 },
46 passkey: { enabled: false },
47 });
48}
49
50// A tiny recording dispatcher so we can assert what the hooks fire.
51function recorder(): {
52 dispatch: AuthEventDispatcher;
53 calls: Array<{ projectId: string; eventType: string; payload: Record<string, unknown> }>;
54} {
55 const calls: Array<{
56 projectId: string;
57 eventType: string;
58 payload: Record<string, unknown>;
59 }> = [];
60 const dispatch: AuthEventDispatcher = (projectId, eventType, payload) => {
61 calls.push({ projectId, eventType, payload });
62 };
63 return { dispatch, calls };
64}
65
66describe('buildTenantAuthPlugins — config-gated plugin loading', () => {
67 test('DEFAULT starter pack loads magic + OTP + passkey', () => {
68 const ids = buildTenantAuthPlugins(PROJECT_ID, DEFAULT_AUTH_CONFIG).map((p) => p.id);
69 expect(ids).toContain('magic-link');
70 expect(ids).toContain('email-otp');
71 expect(ids).toContain('passkey');
72 });
73
74 test('loads no passwordless plugins when all three are disabled', () => {
75 const plugins = buildTenantAuthPlugins(PROJECT_ID, passwordlessOff());
76 const ids = plugins.map((p) => p.id);
77 expect(ids).not.toContain('magic-link');
78 expect(ids).not.toContain('email-otp');
79 expect(ids).not.toContain('passkey');
80 });
81
82 test('loads the passkey plugin when enabled alone', () => {
83 const config = configWith({
84 magicLink: { enabled: false, expiryMinutes: 15 },
85 emailOtp: { enabled: false, codeLength: 6, expiryMinutes: 5 },
86 passkey: { enabled: true },
87 });
88 const ids = buildTenantAuthPlugins(PROJECT_ID, config).map((p) => p.id);
89 expect(ids).toContain('passkey');
90 expect(ids).not.toContain('magic-link');
91 expect(ids).not.toContain('email-otp');
92 });
93
94 test('resolvePasskeyRpId uses customer allowed origin, not briven.tech', () => {
95 expect(
96 resolvePasskeyRpId(
97 { customAuthDomain: null },
98 ['https://code.konnos.org', 'http://localhost:3000'],
99 ),
100 ).toBe('konnos.org');
101 expect(
102 resolvePasskeyRpId(
103 { customAuthDomain: null },
104 ['https://*.mavifinans.sh', 'https://pay.mavifinans.sh'],
105 ),
106 ).toBe('mavifinans.sh');
107 expect(resolvePasskeyRpId({ customAuthDomain: 'auth.murphus.eu' }, [])).toBe(
108 'murphus.eu',
109 );
110 });
111
112 test('registrableDomainFromHost strips subdomains', () => {
113 expect(registrableDomainFromHost('code.konnos.org')).toBe('konnos.org');
114 expect(registrableDomainFromHost('*.mavifinans.sh')).toBe('mavifinans.sh');
115 expect(registrableDomainFromHost('localhost')).toBe('localhost');
116 });
117
118 test('resolveAppOriginForAuthEmail prefers callback host over localhost allowlist', () => {
119 expect(
120 resolveAppOriginForAuthEmail('https://pay.mavifinans.sh/dashboard', [
121 'http://localhost:3000',
122 'https://pay.mavifinans.sh',
123 ]),
124 ).toBe('https://pay.mavifinans.sh');
125 });
126
127 test('rewriteAuthActionUrlToApp puts magic-link verify on the project host', () => {
128 const betterAuthUrl =
129 'https://api.briven.tech/v1/auth-tenant/magic-link/verify?token=tok123&callbackURL=' +
130 encodeURIComponent('https://pay.mavifinans.sh/dashboard');
131 const out = rewriteAuthActionUrlToApp(
132 betterAuthUrl,
133 'p_mavi',
134 { customAuthDomain: null },
135 ['https://pay.mavifinans.sh', 'http://localhost:3000'],
136 );
137 expect(out.startsWith('https://pay.mavifinans.sh' + APP_AUTH_PROXY_PREFIX)).toBe(true);
138 expect(out).toContain('/magic-link/verify');
139 expect(out).toContain('token=tok123');
140 expect(out).not.toContain('api.briven.tech');
141 });
142
143 test('loads the magic-link plugin when enabled alone', () => {
144 const config = configWith({
145 magicLink: { enabled: true, expiryMinutes: 15 },
146 emailOtp: { enabled: false, codeLength: 6, expiryMinutes: 5 },
147 passkey: { enabled: false },
148 });
149 const ids = buildTenantAuthPlugins(PROJECT_ID, config).map((p) => p.id);
150 expect(ids).toContain('magic-link');
151 expect(ids).not.toContain('email-otp');
152 expect(ids).not.toContain('passkey');
153 });
154
155 test('loads the email-OTP plugin when enabled alone', () => {
156 const config = configWith({
157 magicLink: { enabled: false, expiryMinutes: 15 },
158 emailOtp: { enabled: true, codeLength: 6, expiryMinutes: 5 },
159 passkey: { enabled: false },
160 });
161 const ids = buildTenantAuthPlugins(PROJECT_ID, config).map((p) => p.id);
162 expect(ids).toContain('email-otp');
163 expect(ids).not.toContain('magic-link');
164 expect(ids).not.toContain('passkey');
165 });
166
167 test('loads magic + OTP when both are enabled', () => {
168 const config = configWith({
169 magicLink: { enabled: true, expiryMinutes: 15 },
170 emailOtp: { enabled: true, codeLength: 6, expiryMinutes: 5 },
171 passkey: { enabled: false },
172 });
173 const ids = buildTenantAuthPlugins(PROJECT_ID, config).map((p) => p.id);
174 expect(ids).toContain('magic-link');
175 expect(ids).toContain('email-otp');
176 expect(ids).not.toContain('passkey');
177 });
178});
179
180describe('assembleTenantPlugins — unconditional core plugins', () => {
181 test('the jwt plugin is always present, even with everything toggled off', () => {
182 const ids = assembleTenantPlugins([], []).map((p) => p.id);
183 expect(ids).toContain('jwt');
184 });
185
186 test('jwt rides along with passwordless-off config', () => {
187 const passwordless = buildTenantAuthPlugins(PROJECT_ID, passwordlessOff());
188 const ids = assembleTenantPlugins(passwordless, []).map((p) => p.id);
189 expect(ids).toContain('jwt');
190 expect(ids).not.toContain('magic-link');
191 expect(ids).not.toContain('email-otp');
192 });
193
194 test('jwt comes first, then the config-gated plugins in order', () => {
195 const config = configWith({
196 magicLink: { enabled: true, expiryMinutes: 15 },
197 emailOtp: { enabled: false, codeLength: 6, expiryMinutes: 5 },
198 passkey: { enabled: false },
199 });
200 const passwordless = buildTenantAuthPlugins(PROJECT_ID, config);
201 const ids = assembleTenantPlugins(passwordless, []).map((p) => p.id);
202 expect(ids[0]).toBe('jwt');
203 expect(ids).toContain('magic-link');
204 });
205
206 test('generic-oauth still only registers when configs exist', () => {
207 const withNone = assembleTenantPlugins([], []).map((p) => p.id);
208 expect(withNone).not.toContain('generic-oauth');
209
210 const config = configWith({ konnos: { enabled: true, clientId: 'k-cid' } });
211 const oauthConfigs = buildGenericOAuthConfigs(config, { konnos: 'k-secret', oidc: {} });
212 const withKonnos = assembleTenantPlugins([], oauthConfigs).map((p) => p.id);
213 expect(withKonnos).toContain('generic-oauth');
214 });
215});
216
217describe('buildAuthDatabaseHooks — lifecycle webhook dispatch', () => {
218 test('user.create.after fires auth.signup with a minimal payload', async () => {
219 const { dispatch, calls } = recorder();
220 const hooks = buildAuthDatabaseHooks(PROJECT_ID, dispatch);
221 const createdAt = new Date('2026-06-29T00:00:00.000Z');
222
223 await hooks.user!.create!.after!(
224 { id: 'u_1', email: 'a@b.com', createdAt } as never,
225 null,
226 );
227
228 expect(calls).toHaveLength(1);
229 expect(calls[0]!.projectId).toBe(PROJECT_ID);
230 expect(calls[0]!.eventType).toBe('auth.signup');
231 expect(calls[0]!.payload).toEqual({
232 userId: 'u_1',
233 email: 'a@b.com',
234 createdAt: '2026-06-29T00:00:00.000Z',
235 });
236 // No password hash / token leaked into the payload.
237 expect(JSON.stringify(calls[0]!.payload)).not.toContain('password');
238 });
239
240 test('session.create.after fires auth.signin', async () => {
241 const { dispatch, calls } = recorder();
242 const hooks = buildAuthDatabaseHooks(PROJECT_ID, dispatch);
243
244 await hooks.session!.create!.after!(
245 { id: 's_1', userId: 'u_1', token: 'SECRET', createdAt: new Date() } as never,
246 null,
247 );
248
249 expect(calls).toHaveLength(1);
250 expect(calls[0]!.eventType).toBe('auth.signin');
251 expect(calls[0]!.payload.sessionId).toBe('s_1');
252 expect(calls[0]!.payload.userId).toBe('u_1');
253 // The session token must never ride the webhook payload.
254 expect(JSON.stringify(calls[0]!.payload)).not.toContain('SECRET');
255 });
256
257 test('session.delete.after fires auth.signout on a sign-out path', async () => {
258 const { dispatch, calls } = recorder();
259 const hooks = buildAuthDatabaseHooks(PROJECT_ID, dispatch);
260
261 await hooks.session!.delete!.after!(
262 { id: 's_1', userId: 'u_1' } as never,
263 { path: '/sign-out' } as never,
264 );
265
266 expect(calls).toHaveLength(1);
267 expect(calls[0]!.eventType).toBe('auth.signout');
268 });
269
270 test('session.delete.after fires auth.session.revoked on a revoke path', async () => {
271 const { dispatch, calls } = recorder();
272 const hooks = buildAuthDatabaseHooks(PROJECT_ID, dispatch);
273
274 await hooks.session!.delete!.after!(
275 { id: 's_1', userId: 'u_1' } as never,
276 { path: '/revoke-session' } as never,
277 );
278
279 expect(calls).toHaveLength(1);
280 expect(calls[0]!.eventType).toBe('auth.session.revoked');
281 });
282
283 test('a missing context falls back to auth.signout', async () => {
284 const { dispatch, calls } = recorder();
285 const hooks = buildAuthDatabaseHooks(PROJECT_ID, dispatch);
286
287 await hooks.session!.delete!.after!({ id: 's_1', userId: 'u_1' } as never, null);
288
289 expect(calls[0]!.eventType).toBe('auth.signout');
290 });
291});
292
293describe('buildGenericOAuthConfigs — konnos + custom OIDC wiring', () => {
294 function oidc(overrides: Partial<CustomOidcProvider> = {}): CustomOidcProvider {
295 return {
296 id: 'acme-sso',
297 displayName: 'Acme SSO',
298 enabled: true,
299 clientId: 'acme-public-client-id',
300 issuer: 'https://issuer.example.com',
301 authorizationUrl: null,
302 tokenUrl: null,
303 userinfoUrl: null,
304 scopes: 'openid profile email',
305 pkce: true,
306 ...overrides,
307 };
308 }
309
310 test('wires konnos only when enabled + clientId + secret are all present', () => {
311 const config = configWith({ konnos: { enabled: true, clientId: 'k-cid' } });
312 const withSecret = buildGenericOAuthConfigs(config, { konnos: 'k-secret', oidc: {} });
313 expect(withSecret.map((e) => e.providerId)).toEqual(['konnos']);
314
315 const noSecret = buildGenericOAuthConfigs(config, { konnos: null, oidc: {} });
316 expect(noSecret).toHaveLength(0);
317 });
318
319 test('wires a fully-configured issuer-based custom-OIDC provider via discoveryUrl', () => {
320 const config: AuthConfig = { ...DEFAULT_AUTH_CONFIG, customOidc: [oidc()] };
321 const entries = buildGenericOAuthConfigs(config, {
322 konnos: null,
323 oidc: { 'acme-sso': 'oidc-secret' },
324 });
325 expect(entries).toHaveLength(1);
326 const e = entries[0]! as {
327 providerId: string;
328 clientId: string;
329 clientSecret?: string;
330 scopes?: string[];
331 pkce?: boolean;
332 issuer?: string;
333 discoveryUrl?: string;
334 };
335 expect(e.providerId).toBe('acme-sso');
336 expect(e.clientId).toBe('acme-public-client-id');
337 expect(e.clientSecret).toBe('oidc-secret');
338 expect(e.scopes).toEqual(['openid', 'profile', 'email']);
339 expect(e.pkce).toBe(true);
340 expect(e.discoveryUrl).toBe(
341 'https://issuer.example.com/.well-known/openid-configuration',
342 );
343 });
344
345 test('skips a custom-OIDC provider with no stored secret', () => {
346 const config: AuthConfig = { ...DEFAULT_AUTH_CONFIG, customOidc: [oidc()] };
347 const entries = buildGenericOAuthConfigs(config, {
348 konnos: null,
349 oidc: { 'acme-sso': null },
350 });
351 expect(entries).toHaveLength(0);
352 });
353
354 test('skips a disabled or endpoint-less custom-OIDC provider', () => {
355 const config: AuthConfig = {
356 ...DEFAULT_AUTH_CONFIG,
357 customOidc: [
358 oidc({ id: 'disabled', enabled: false }),
359 oidc({ id: 'no-endpoints', issuer: null }),
360 ],
361 };
362 const entries = buildGenericOAuthConfigs(config, {
363 konnos: null,
364 oidc: { disabled: 'x', 'no-endpoints': 'y' },
365 });
366 expect(entries).toHaveLength(0);
367 });
368
369 test('wires a custom-OIDC provider configured with explicit endpoints (no issuer)', () => {
370 const config: AuthConfig = {
371 ...DEFAULT_AUTH_CONFIG,
372 customOidc: [
373 oidc({
374 id: 'explicit',
375 issuer: null,
376 authorizationUrl: 'https://i.example.com/authorize',
377 tokenUrl: 'https://i.example.com/token',
378 userinfoUrl: 'https://i.example.com/userinfo',
379 }),
380 ],
381 };
382 const entries = buildGenericOAuthConfigs(config, {
383 konnos: null,
384 oidc: { explicit: 'sec' },
385 });
386 expect(entries).toHaveLength(1);
387 const e = entries[0]! as {
388 providerId: string;
389 authorizationUrl?: string;
390 tokenUrl?: string;
391 userInfoUrl?: string;
392 discoveryUrl?: string;
393 };
394 expect(e.providerId).toBe('explicit');
395 expect(e.authorizationUrl).toBe('https://i.example.com/authorize');
396 expect(e.tokenUrl).toBe('https://i.example.com/token');
397 expect(e.userInfoUrl).toBe('https://i.example.com/userinfo');
398 expect(e.discoveryUrl).toBeUndefined();
399 });
400
401 test('combines konnos and custom OIDC in one config array', () => {
402 const config: AuthConfig = {
403 ...DEFAULT_AUTH_CONFIG,
404 providers: {
405 ...DEFAULT_AUTH_CONFIG.providers,
406 konnos: { enabled: true, clientId: 'k-cid' },
407 },
408 customOidc: [oidc()],
409 };
410 const entries = buildGenericOAuthConfigs(config, {
411 konnos: 'k-secret',
412 oidc: { 'acme-sso': 'oidc-secret' },
413 });
414 expect(entries.map((e) => e.providerId)).toEqual(['konnos', 'acme-sso']);
415 });
416});
417
418describe('resetPasswordUrl — hosted new-password contract', () => {
419 test('builds <authBaseUrl>/auth/<projectId>/new-password?token=<token>', () => {
420 const url = resetPasswordUrl(PROJECT_ID, 'tok123');
421 expect(url).toBe(`${hostedAuthBaseUrl(PROJECT_ID)}/auth/${PROJECT_ID}/new-password?token=tok123`);
422 expect(url).toContain('/auth/p_test123/new-password?token=');
423 });
424
425 test('url-encodes tokens with reserved characters', () => {
426 const url = resetPasswordUrl(PROJECT_ID, 'a/b+c=d');
427 expect(url).toContain('token=a%2Fb%2Bc%3Dd');
428 });
429});