@objectstack/plugin-auth 4.0.3 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -434,7 +434,7 @@ describe('AuthManager', () => {
434
434
  ]);
435
435
  });
436
436
 
437
- it('should NOT include trustedOrigins key when not provided', () => {
437
+ it('should default to localhost wildcard when trustedOrigins not provided', () => {
438
438
  let capturedConfig: any;
439
439
  (betterAuth as any).mockImplementation((config: any) => {
440
440
  capturedConfig = config;
@@ -449,10 +449,10 @@ describe('AuthManager', () => {
449
449
  manager.getAuthInstance();
450
450
  warnSpy.mockRestore();
451
451
 
452
- expect(capturedConfig).not.toHaveProperty('trustedOrigins');
452
+ expect(capturedConfig.trustedOrigins).toEqual(['http://localhost:*']);
453
453
  });
454
454
 
455
- it('should NOT include trustedOrigins key when array is empty', () => {
455
+ it('should default to localhost wildcard when trustedOrigins array is empty', () => {
456
456
  let capturedConfig: any;
457
457
  (betterAuth as any).mockImplementation((config: any) => {
458
458
  capturedConfig = config;
@@ -468,7 +468,7 @@ describe('AuthManager', () => {
468
468
  manager.getAuthInstance();
469
469
  warnSpy.mockRestore();
470
470
 
471
- expect(capturedConfig).not.toHaveProperty('trustedOrigins');
471
+ expect(capturedConfig.trustedOrigins).toEqual(['http://localhost:*']);
472
472
  });
473
473
  });
474
474
 
@@ -755,4 +755,129 @@ describe('AuthManager', () => {
755
755
  expect(capturedConfig).not.toHaveProperty('advanced');
756
756
  });
757
757
  });
758
+
759
+ describe('getPublicConfig', () => {
760
+ it('should return safe public configuration', () => {
761
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
762
+ const manager = new AuthManager({
763
+ secret: 'test-secret-at-least-32-chars-long',
764
+ baseUrl: 'http://localhost:3000',
765
+ socialProviders: {
766
+ google: {
767
+ clientId: 'google-client-id',
768
+ clientSecret: 'google-client-secret',
769
+ enabled: true,
770
+ },
771
+ github: {
772
+ clientId: 'github-client-id',
773
+ clientSecret: 'github-client-secret',
774
+ },
775
+ },
776
+ emailAndPassword: {
777
+ enabled: true,
778
+ disableSignUp: false,
779
+ requireEmailVerification: true,
780
+ },
781
+ plugins: {
782
+ twoFactor: true,
783
+ organization: true,
784
+ },
785
+ });
786
+ warnSpy.mockRestore();
787
+
788
+ const config = manager.getPublicConfig();
789
+
790
+ // Should include social providers without secrets
791
+ expect(config.socialProviders).toHaveLength(2);
792
+ expect(config.socialProviders[0]).toEqual({
793
+ id: 'google',
794
+ name: 'Google',
795
+ enabled: true,
796
+ });
797
+ expect(config.socialProviders[1]).toEqual({
798
+ id: 'github',
799
+ name: 'GitHub',
800
+ enabled: true,
801
+ });
802
+
803
+ // Should NOT include sensitive data
804
+ expect(config).not.toHaveProperty('secret');
805
+ expect(config.socialProviders[0]).not.toHaveProperty('clientSecret');
806
+ expect(config.socialProviders[0]).not.toHaveProperty('clientId');
807
+
808
+ // Should include email/password config
809
+ expect(config.emailPassword).toEqual({
810
+ enabled: true,
811
+ disableSignUp: false,
812
+ requireEmailVerification: true,
813
+ });
814
+
815
+ // Should include features
816
+ expect(config.features).toEqual({
817
+ twoFactor: true,
818
+ passkeys: false,
819
+ magicLink: false,
820
+ organization: true,
821
+ });
822
+ });
823
+
824
+ it('should filter out disabled providers', () => {
825
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
826
+ const manager = new AuthManager({
827
+ secret: 'test-secret-at-least-32-chars-long',
828
+ socialProviders: {
829
+ google: {
830
+ clientId: 'google-client-id',
831
+ clientSecret: 'google-client-secret',
832
+ enabled: true,
833
+ },
834
+ github: {
835
+ clientId: 'github-client-id',
836
+ clientSecret: 'github-client-secret',
837
+ enabled: false,
838
+ },
839
+ },
840
+ });
841
+ warnSpy.mockRestore();
842
+
843
+ const config = manager.getPublicConfig();
844
+
845
+ expect(config.socialProviders).toHaveLength(1);
846
+ expect(config.socialProviders[0].id).toBe('google');
847
+ });
848
+
849
+ it('should default email/password to enabled', () => {
850
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
851
+ const manager = new AuthManager({
852
+ secret: 'test-secret-at-least-32-chars-long',
853
+ });
854
+ warnSpy.mockRestore();
855
+
856
+ const config = manager.getPublicConfig();
857
+
858
+ expect(config.emailPassword.enabled).toBe(true);
859
+ });
860
+
861
+ it('should handle unknown provider names', () => {
862
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
863
+ const manager = new AuthManager({
864
+ secret: 'test-secret-at-least-32-chars-long',
865
+ socialProviders: {
866
+ customProvider: {
867
+ clientId: 'custom-client-id',
868
+ clientSecret: 'custom-client-secret',
869
+ },
870
+ },
871
+ });
872
+ warnSpy.mockRestore();
873
+
874
+ const config = manager.getPublicConfig();
875
+
876
+ expect(config.socialProviders[0]).toEqual({
877
+ id: 'customProvider',
878
+ name: 'CustomProvider',
879
+ enabled: true,
880
+ });
881
+ });
882
+ });
758
883
  });
@@ -5,7 +5,11 @@ import type { Auth, BetterAuthOptions } from 'better-auth';
5
5
  import { organization } from 'better-auth/plugins/organization';
6
6
  import { twoFactor } from 'better-auth/plugins/two-factor';
7
7
  import { magicLink } from 'better-auth/plugins/magic-link';
8
- import type { AuthConfig } from '@objectstack/spec/system';
8
+ import type {
9
+ AuthConfig,
10
+ EmailAndPasswordConfig,
11
+ AuthPluginConfig,
12
+ } from '@objectstack/spec/system';
9
13
  import type { IDataEngine } from '@objectstack/core';
10
14
  import { createObjectQLAdapterFactory } from './objectql-adapter.js';
11
15
  import {
@@ -153,7 +157,23 @@ export class AuthManager {
153
157
  plugins: this.buildPluginList(),
154
158
 
155
159
  // Trusted origins for CSRF protection (supports wildcards like "https://*.example.com")
156
- ...(this.config.trustedOrigins?.length ? { trustedOrigins: this.config.trustedOrigins } : {}),
160
+ // Auto-includes origins from CORS_ORIGIN env var so CORS and CSRF stay in sync.
161
+ ...(() => {
162
+ const origins: string[] = [...(this.config.trustedOrigins || [])];
163
+ // Sync with CORS_ORIGIN env var (comma-separated)
164
+ const corsOrigin = process.env.CORS_ORIGIN;
165
+ if (corsOrigin && corsOrigin !== '*') {
166
+ corsOrigin.split(',').map(s => s.trim()).filter(Boolean).forEach(o => {
167
+ if (!origins.includes(o)) origins.push(o);
168
+ });
169
+ }
170
+ // When CORS allows all origins (default) and no explicit trustedOrigins,
171
+ // trust all localhost ports in development for convenience.
172
+ if (!origins.length && (!corsOrigin || corsOrigin === '*')) {
173
+ origins.push('http://localhost:*');
174
+ }
175
+ return origins.length ? { trustedOrigins: origins } : {};
176
+ })(),
157
177
 
158
178
  // Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
159
179
  ...(this.config.advanced ? {
@@ -335,4 +355,65 @@ export class AuthManager {
335
355
  get api() {
336
356
  return this.getOrCreateAuth().api;
337
357
  }
358
+
359
+ /**
360
+ * Get public authentication configuration
361
+ * Returns safe, non-sensitive configuration that can be exposed to the frontend
362
+ *
363
+ * This allows the frontend to discover:
364
+ * - Which social/OAuth providers are available
365
+ * - Whether email/password login is enabled
366
+ * - Which advanced features are enabled (2FA, magic links, etc.)
367
+ */
368
+ getPublicConfig() {
369
+ // Extract social providers info (without sensitive data)
370
+ const socialProviders = [];
371
+ if (this.config.socialProviders) {
372
+ for (const [id, providerConfig] of Object.entries(this.config.socialProviders)) {
373
+ if (providerConfig.enabled !== false) {
374
+ // Map provider ID to friendly name
375
+ const nameMap: Record<string, string> = {
376
+ google: 'Google',
377
+ github: 'GitHub',
378
+ microsoft: 'Microsoft',
379
+ apple: 'Apple',
380
+ facebook: 'Facebook',
381
+ twitter: 'Twitter',
382
+ discord: 'Discord',
383
+ gitlab: 'GitLab',
384
+ linkedin: 'LinkedIn',
385
+ };
386
+
387
+ socialProviders.push({
388
+ id,
389
+ name: nameMap[id] || id.charAt(0).toUpperCase() + id.slice(1),
390
+ enabled: true,
391
+ });
392
+ }
393
+ }
394
+ }
395
+
396
+ // Extract email/password config (safe fields only)
397
+ const emailPasswordConfig: Partial<EmailAndPasswordConfig> = this.config.emailAndPassword ?? {};
398
+ const emailPassword = {
399
+ enabled: emailPasswordConfig.enabled !== false, // Default to true
400
+ disableSignUp: emailPasswordConfig.disableSignUp ?? false,
401
+ requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false,
402
+ };
403
+
404
+ // Extract enabled features
405
+ const pluginConfig: Partial<AuthPluginConfig> = this.config.plugins ?? {};
406
+ const features = {
407
+ twoFactor: pluginConfig.twoFactor ?? false,
408
+ passkeys: pluginConfig.passkeys ?? false,
409
+ magicLink: pluginConfig.magicLink ?? false,
410
+ organization: pluginConfig.organization ?? false,
411
+ };
412
+
413
+ return {
414
+ emailPassword,
415
+ socialProviders,
416
+ features,
417
+ };
418
+ }
338
419
  }
@@ -137,6 +137,7 @@ describe('AuthPlugin', () => {
137
137
  it('should register routes with HTTP server on kernel:ready', async () => {
138
138
  const mockRawApp = {
139
139
  all: vi.fn(),
140
+ get: vi.fn(),
140
141
  };
141
142
 
142
143
  const mockHttpServer = {
@@ -173,6 +174,7 @@ describe('AuthPlugin', () => {
173
174
  it('should log via ctx.logger when better-auth returns a 500 response', async () => {
174
175
  const mockRawApp = {
175
176
  all: vi.fn(),
177
+ get: vi.fn(),
176
178
  };
177
179
 
178
180
  const mockHttpServer = {
@@ -270,7 +272,7 @@ describe('AuthPlugin', () => {
270
272
  });
271
273
 
272
274
  it('should auto-detect baseUrl from http-server port when port differs', async () => {
273
- const mockRawApp = { all: vi.fn() };
275
+ const mockRawApp = { all: vi.fn(), get: vi.fn() };
274
276
  const mockHttpServer = {
275
277
  post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
276
278
  patch: vi.fn(), use: vi.fn(),
@@ -306,7 +308,7 @@ describe('AuthPlugin', () => {
306
308
  (mockContext.registerService as any).mockClear();
307
309
  await localPlugin.init(mockContext);
308
310
 
309
- const mockRawApp = { all: vi.fn() };
311
+ const mockRawApp = { all: vi.fn(), get: vi.fn() };
310
312
  const mockHttpServer = {
311
313
  post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
312
314
  patch: vi.fn(), use: vi.fn(),
@@ -338,7 +340,7 @@ describe('AuthPlugin', () => {
338
340
  (mockContext.registerService as any).mockClear();
339
341
  await localPlugin.init(mockContext);
340
342
 
341
- const mockRawApp = { all: vi.fn() };
343
+ const mockRawApp = { all: vi.fn(), get: vi.fn() };
342
344
  const mockHttpServer = {
343
345
  post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
344
346
  patch: vi.fn(), use: vi.fn(),
@@ -400,6 +402,7 @@ describe('AuthPlugin', () => {
400
402
 
401
403
  const mockRawApp = {
402
404
  all: vi.fn(),
405
+ get: vi.fn(),
403
406
  };
404
407
 
405
408
  const mockHttpServer = {
@@ -245,6 +245,28 @@ export class AuthPlugin implements Plugin {
245
245
 
246
246
  const rawApp = (httpServer as any).getRawApp();
247
247
 
248
+ // Register auth config endpoint - public endpoint for frontend discovery
249
+ rawApp.get(`${basePath}/config`, async (c: any) => {
250
+ try {
251
+ const config = this.authManager!.getPublicConfig();
252
+ return c.json({
253
+ success: true,
254
+ data: config,
255
+ });
256
+ } catch (error) {
257
+ const err = error instanceof Error ? error : new Error(String(error));
258
+ ctx.logger.error('Auth config error:', err);
259
+
260
+ return c.json({
261
+ success: false,
262
+ error: {
263
+ code: 'auth_config_error',
264
+ message: err.message,
265
+ },
266
+ }, 500);
267
+ }
268
+ });
269
+
248
270
  // Register wildcard route to forward all auth requests to better-auth.
249
271
  // better-auth is configured with basePath matching our route prefix, so we
250
272
  // forward the original request directly — no path rewriting needed.