@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +8 -0
- package/dist/index.d.mts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +86 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +86 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/auth-manager.test.ts +129 -4
- package/src/auth-manager.ts +83 -2
- package/src/auth-plugin.test.ts +6 -3
- package/src/auth-plugin.ts +22 -0
package/src/auth-manager.test.ts
CHANGED
|
@@ -434,7 +434,7 @@ describe('AuthManager', () => {
|
|
|
434
434
|
]);
|
|
435
435
|
});
|
|
436
436
|
|
|
437
|
-
it('should
|
|
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).
|
|
452
|
+
expect(capturedConfig.trustedOrigins).toEqual(['http://localhost:*']);
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
-
it('should
|
|
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).
|
|
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
|
});
|
package/src/auth-manager.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/auth-plugin.test.ts
CHANGED
|
@@ -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 = {
|
package/src/auth-plugin.ts
CHANGED
|
@@ -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.
|