@objectstack/plugin-auth 3.2.6 → 3.2.7
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 +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +66 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +66 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/auth-manager.test.ts +345 -0
- package/src/auth-manager.ts +71 -2
- package/src/auth-plugin.test.ts +89 -0
- package/src/auth-plugin.ts +21 -0
package/src/auth-manager.test.ts
CHANGED
|
@@ -410,4 +410,349 @@ describe('AuthManager', () => {
|
|
|
410
410
|
);
|
|
411
411
|
});
|
|
412
412
|
});
|
|
413
|
+
|
|
414
|
+
describe('trustedOrigins passthrough', () => {
|
|
415
|
+
it('should forward trustedOrigins to betterAuth when provided', () => {
|
|
416
|
+
let capturedConfig: any;
|
|
417
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
418
|
+
capturedConfig = config;
|
|
419
|
+
return { handler: vi.fn(), api: {} };
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
423
|
+
const manager = new AuthManager({
|
|
424
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
425
|
+
baseUrl: 'http://localhost:3000',
|
|
426
|
+
trustedOrigins: ['https://*.objectos.app', 'http://localhost:*'],
|
|
427
|
+
});
|
|
428
|
+
manager.getAuthInstance();
|
|
429
|
+
warnSpy.mockRestore();
|
|
430
|
+
|
|
431
|
+
expect(capturedConfig.trustedOrigins).toEqual([
|
|
432
|
+
'https://*.objectos.app',
|
|
433
|
+
'http://localhost:*',
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should NOT include trustedOrigins key when not provided', () => {
|
|
438
|
+
let capturedConfig: any;
|
|
439
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
440
|
+
capturedConfig = config;
|
|
441
|
+
return { handler: vi.fn(), api: {} };
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
445
|
+
const manager = new AuthManager({
|
|
446
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
447
|
+
baseUrl: 'http://localhost:3000',
|
|
448
|
+
});
|
|
449
|
+
manager.getAuthInstance();
|
|
450
|
+
warnSpy.mockRestore();
|
|
451
|
+
|
|
452
|
+
expect(capturedConfig).not.toHaveProperty('trustedOrigins');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should NOT include trustedOrigins key when array is empty', () => {
|
|
456
|
+
let capturedConfig: any;
|
|
457
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
458
|
+
capturedConfig = config;
|
|
459
|
+
return { handler: vi.fn(), api: {} };
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
463
|
+
const manager = new AuthManager({
|
|
464
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
465
|
+
baseUrl: 'http://localhost:3000',
|
|
466
|
+
trustedOrigins: [],
|
|
467
|
+
});
|
|
468
|
+
manager.getAuthInstance();
|
|
469
|
+
warnSpy.mockRestore();
|
|
470
|
+
|
|
471
|
+
expect(capturedConfig).not.toHaveProperty('trustedOrigins');
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe('setRuntimeBaseUrl', () => {
|
|
476
|
+
it('should update baseURL before auth instance is created', () => {
|
|
477
|
+
let capturedConfig: any;
|
|
478
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
479
|
+
capturedConfig = config;
|
|
480
|
+
return { handler: vi.fn(), api: {} };
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
484
|
+
const manager = new AuthManager({
|
|
485
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
486
|
+
baseUrl: 'http://localhost:3000',
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
manager.setRuntimeBaseUrl('http://localhost:3002');
|
|
490
|
+
manager.getAuthInstance();
|
|
491
|
+
warnSpy.mockRestore();
|
|
492
|
+
|
|
493
|
+
expect(capturedConfig.baseURL).toBe('http://localhost:3002');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should be a no-op and warn when called after auth instance is created', () => {
|
|
497
|
+
let capturedConfig: any;
|
|
498
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
499
|
+
capturedConfig = config;
|
|
500
|
+
return { handler: vi.fn(), api: {} };
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
504
|
+
const manager = new AuthManager({
|
|
505
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
506
|
+
baseUrl: 'http://localhost:3000',
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Force auth instance creation
|
|
510
|
+
manager.getAuthInstance();
|
|
511
|
+
expect(capturedConfig.baseURL).toBe('http://localhost:3000');
|
|
512
|
+
|
|
513
|
+
// Now try to change — should warn and not affect the already-created instance
|
|
514
|
+
manager.setRuntimeBaseUrl('http://localhost:4000');
|
|
515
|
+
|
|
516
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
517
|
+
expect.stringContaining('setRuntimeBaseUrl() called after the auth instance was already created'),
|
|
518
|
+
);
|
|
519
|
+
warnSpy.mockRestore();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should override the default fallback (localhost:3000) when no baseUrl was configured', () => {
|
|
523
|
+
let capturedConfig: any;
|
|
524
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
525
|
+
capturedConfig = config;
|
|
526
|
+
return { handler: vi.fn(), api: {} };
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
530
|
+
const manager = new AuthManager({
|
|
531
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
manager.setRuntimeBaseUrl('http://localhost:3002');
|
|
535
|
+
manager.getAuthInstance();
|
|
536
|
+
warnSpy.mockRestore();
|
|
537
|
+
|
|
538
|
+
expect(capturedConfig.baseURL).toBe('http://localhost:3002');
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('socialProviders passthrough', () => {
|
|
543
|
+
it('should forward socialProviders to betterAuth when provided', () => {
|
|
544
|
+
let capturedConfig: any;
|
|
545
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
546
|
+
capturedConfig = config;
|
|
547
|
+
return { handler: vi.fn(), api: {} };
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
551
|
+
const manager = new AuthManager({
|
|
552
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
553
|
+
baseUrl: 'http://localhost:3000',
|
|
554
|
+
socialProviders: {
|
|
555
|
+
google: { clientId: 'gid', clientSecret: 'gsecret' },
|
|
556
|
+
github: { clientId: 'ghid', clientSecret: 'ghsecret' },
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
manager.getAuthInstance();
|
|
560
|
+
warnSpy.mockRestore();
|
|
561
|
+
|
|
562
|
+
expect(capturedConfig.socialProviders).toEqual({
|
|
563
|
+
google: { clientId: 'gid', clientSecret: 'gsecret' },
|
|
564
|
+
github: { clientId: 'ghid', clientSecret: 'ghsecret' },
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('should NOT include socialProviders when not provided', () => {
|
|
569
|
+
let capturedConfig: any;
|
|
570
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
571
|
+
capturedConfig = config;
|
|
572
|
+
return { handler: vi.fn(), api: {} };
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
576
|
+
const manager = new AuthManager({
|
|
577
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
578
|
+
baseUrl: 'http://localhost:3000',
|
|
579
|
+
});
|
|
580
|
+
manager.getAuthInstance();
|
|
581
|
+
warnSpy.mockRestore();
|
|
582
|
+
|
|
583
|
+
expect(capturedConfig).not.toHaveProperty('socialProviders');
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe('emailAndPassword passthrough', () => {
|
|
588
|
+
it('should default emailAndPassword to enabled: true', () => {
|
|
589
|
+
let capturedConfig: any;
|
|
590
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
591
|
+
capturedConfig = config;
|
|
592
|
+
return { handler: vi.fn(), api: {} };
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
596
|
+
const manager = new AuthManager({
|
|
597
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
598
|
+
baseUrl: 'http://localhost:3000',
|
|
599
|
+
});
|
|
600
|
+
manager.getAuthInstance();
|
|
601
|
+
warnSpy.mockRestore();
|
|
602
|
+
|
|
603
|
+
expect(capturedConfig.emailAndPassword.enabled).toBe(true);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('should forward extended emailAndPassword options', () => {
|
|
607
|
+
let capturedConfig: any;
|
|
608
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
609
|
+
capturedConfig = config;
|
|
610
|
+
return { handler: vi.fn(), api: {} };
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
614
|
+
const manager = new AuthManager({
|
|
615
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
616
|
+
baseUrl: 'http://localhost:3000',
|
|
617
|
+
emailAndPassword: {
|
|
618
|
+
enabled: true,
|
|
619
|
+
minPasswordLength: 12,
|
|
620
|
+
maxPasswordLength: 64,
|
|
621
|
+
requireEmailVerification: true,
|
|
622
|
+
autoSignIn: false,
|
|
623
|
+
revokeSessionsOnPasswordReset: true,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
manager.getAuthInstance();
|
|
627
|
+
warnSpy.mockRestore();
|
|
628
|
+
|
|
629
|
+
expect(capturedConfig.emailAndPassword).toEqual({
|
|
630
|
+
enabled: true,
|
|
631
|
+
minPasswordLength: 12,
|
|
632
|
+
maxPasswordLength: 64,
|
|
633
|
+
requireEmailVerification: true,
|
|
634
|
+
autoSignIn: false,
|
|
635
|
+
revokeSessionsOnPasswordReset: true,
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe('emailVerification passthrough', () => {
|
|
641
|
+
it('should forward emailVerification when provided', () => {
|
|
642
|
+
let capturedConfig: any;
|
|
643
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
644
|
+
capturedConfig = config;
|
|
645
|
+
return { handler: vi.fn(), api: {} };
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
649
|
+
const manager = new AuthManager({
|
|
650
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
651
|
+
baseUrl: 'http://localhost:3000',
|
|
652
|
+
emailVerification: {
|
|
653
|
+
sendOnSignUp: true,
|
|
654
|
+
expiresIn: 1800,
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
manager.getAuthInstance();
|
|
658
|
+
warnSpy.mockRestore();
|
|
659
|
+
|
|
660
|
+
expect(capturedConfig.emailVerification).toEqual({
|
|
661
|
+
sendOnSignUp: true,
|
|
662
|
+
expiresIn: 1800,
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('should NOT include emailVerification when not provided', () => {
|
|
667
|
+
let capturedConfig: any;
|
|
668
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
669
|
+
capturedConfig = config;
|
|
670
|
+
return { handler: vi.fn(), api: {} };
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
674
|
+
const manager = new AuthManager({
|
|
675
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
676
|
+
baseUrl: 'http://localhost:3000',
|
|
677
|
+
});
|
|
678
|
+
manager.getAuthInstance();
|
|
679
|
+
warnSpy.mockRestore();
|
|
680
|
+
|
|
681
|
+
expect(capturedConfig).not.toHaveProperty('emailVerification');
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe('advanced options passthrough', () => {
|
|
686
|
+
it('should forward crossSubDomainCookies when provided', () => {
|
|
687
|
+
let capturedConfig: any;
|
|
688
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
689
|
+
capturedConfig = config;
|
|
690
|
+
return { handler: vi.fn(), api: {} };
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
694
|
+
const manager = new AuthManager({
|
|
695
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
696
|
+
baseUrl: 'http://localhost:3000',
|
|
697
|
+
advanced: {
|
|
698
|
+
crossSubDomainCookies: {
|
|
699
|
+
enabled: true,
|
|
700
|
+
domain: '.objectos.app',
|
|
701
|
+
},
|
|
702
|
+
useSecureCookies: true,
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
manager.getAuthInstance();
|
|
706
|
+
warnSpy.mockRestore();
|
|
707
|
+
|
|
708
|
+
expect(capturedConfig.advanced).toEqual({
|
|
709
|
+
crossSubDomainCookies: {
|
|
710
|
+
enabled: true,
|
|
711
|
+
domain: '.objectos.app',
|
|
712
|
+
},
|
|
713
|
+
useSecureCookies: true,
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('should forward cookiePrefix and disableCSRFCheck', () => {
|
|
718
|
+
let capturedConfig: any;
|
|
719
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
720
|
+
capturedConfig = config;
|
|
721
|
+
return { handler: vi.fn(), api: {} };
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
725
|
+
const manager = new AuthManager({
|
|
726
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
727
|
+
baseUrl: 'http://localhost:3000',
|
|
728
|
+
advanced: {
|
|
729
|
+
disableCSRFCheck: true,
|
|
730
|
+
cookiePrefix: 'objectos',
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
manager.getAuthInstance();
|
|
734
|
+
warnSpy.mockRestore();
|
|
735
|
+
|
|
736
|
+
expect(capturedConfig.advanced.disableCSRFCheck).toBe(true);
|
|
737
|
+
expect(capturedConfig.advanced.cookiePrefix).toBe('objectos');
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('should NOT include advanced when not provided', () => {
|
|
741
|
+
let capturedConfig: any;
|
|
742
|
+
(betterAuth as any).mockImplementation((config: any) => {
|
|
743
|
+
capturedConfig = config;
|
|
744
|
+
return { handler: vi.fn(), api: {} };
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
748
|
+
const manager = new AuthManager({
|
|
749
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
750
|
+
baseUrl: 'http://localhost:3000',
|
|
751
|
+
});
|
|
752
|
+
manager.getAuthInstance();
|
|
753
|
+
warnSpy.mockRestore();
|
|
754
|
+
|
|
755
|
+
expect(capturedConfig).not.toHaveProperty('advanced');
|
|
756
|
+
});
|
|
757
|
+
});
|
|
413
758
|
});
|
package/src/auth-manager.ts
CHANGED
|
@@ -106,11 +106,42 @@ export class AuthManager {
|
|
|
106
106
|
...AUTH_VERIFICATION_CONFIG,
|
|
107
107
|
},
|
|
108
108
|
|
|
109
|
-
//
|
|
109
|
+
// Social / OAuth providers
|
|
110
|
+
...(this.config.socialProviders ? { socialProviders: this.config.socialProviders as any } : {}),
|
|
111
|
+
|
|
112
|
+
// Email and password configuration
|
|
110
113
|
emailAndPassword: {
|
|
111
|
-
enabled: true,
|
|
114
|
+
enabled: this.config.emailAndPassword?.enabled ?? true,
|
|
115
|
+
...(this.config.emailAndPassword?.disableSignUp != null
|
|
116
|
+
? { disableSignUp: this.config.emailAndPassword.disableSignUp } : {}),
|
|
117
|
+
...(this.config.emailAndPassword?.requireEmailVerification != null
|
|
118
|
+
? { requireEmailVerification: this.config.emailAndPassword.requireEmailVerification } : {}),
|
|
119
|
+
...(this.config.emailAndPassword?.minPasswordLength != null
|
|
120
|
+
? { minPasswordLength: this.config.emailAndPassword.minPasswordLength } : {}),
|
|
121
|
+
...(this.config.emailAndPassword?.maxPasswordLength != null
|
|
122
|
+
? { maxPasswordLength: this.config.emailAndPassword.maxPasswordLength } : {}),
|
|
123
|
+
...(this.config.emailAndPassword?.resetPasswordTokenExpiresIn != null
|
|
124
|
+
? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {}),
|
|
125
|
+
...(this.config.emailAndPassword?.autoSignIn != null
|
|
126
|
+
? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {}),
|
|
127
|
+
...(this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null
|
|
128
|
+
? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}),
|
|
112
129
|
},
|
|
113
130
|
|
|
131
|
+
// Email verification
|
|
132
|
+
...(this.config.emailVerification ? {
|
|
133
|
+
emailVerification: {
|
|
134
|
+
...(this.config.emailVerification.sendOnSignUp != null
|
|
135
|
+
? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {}),
|
|
136
|
+
...(this.config.emailVerification.sendOnSignIn != null
|
|
137
|
+
? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {}),
|
|
138
|
+
...(this.config.emailVerification.autoSignInAfterVerification != null
|
|
139
|
+
? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {}),
|
|
140
|
+
...(this.config.emailVerification.expiresIn != null
|
|
141
|
+
? { expiresIn: this.config.emailVerification.expiresIn } : {}),
|
|
142
|
+
},
|
|
143
|
+
} : {}),
|
|
144
|
+
|
|
114
145
|
// Session configuration
|
|
115
146
|
session: {
|
|
116
147
|
...AUTH_SESSION_CONFIG,
|
|
@@ -120,6 +151,23 @@ export class AuthManager {
|
|
|
120
151
|
|
|
121
152
|
// better-auth plugins — registered based on AuthPluginConfig flags
|
|
122
153
|
plugins: this.buildPluginList(),
|
|
154
|
+
|
|
155
|
+
// Trusted origins for CSRF protection (supports wildcards like "https://*.example.com")
|
|
156
|
+
...(this.config.trustedOrigins?.length ? { trustedOrigins: this.config.trustedOrigins } : {}),
|
|
157
|
+
|
|
158
|
+
// Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
|
|
159
|
+
...(this.config.advanced ? {
|
|
160
|
+
advanced: {
|
|
161
|
+
...(this.config.advanced.crossSubDomainCookies
|
|
162
|
+
? { crossSubDomainCookies: this.config.advanced.crossSubDomainCookies } : {}),
|
|
163
|
+
...(this.config.advanced.useSecureCookies != null
|
|
164
|
+
? { useSecureCookies: this.config.advanced.useSecureCookies } : {}),
|
|
165
|
+
...(this.config.advanced.disableCSRFCheck != null
|
|
166
|
+
? { disableCSRFCheck: this.config.advanced.disableCSRFCheck } : {}),
|
|
167
|
+
...(this.config.advanced.cookiePrefix != null
|
|
168
|
+
? { cookiePrefix: this.config.advanced.cookiePrefix } : {}),
|
|
169
|
+
},
|
|
170
|
+
} : {}),
|
|
123
171
|
};
|
|
124
172
|
|
|
125
173
|
return betterAuth(betterAuthConfig);
|
|
@@ -224,6 +272,27 @@ export class AuthManager {
|
|
|
224
272
|
return envSecret;
|
|
225
273
|
}
|
|
226
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Update the base URL at runtime.
|
|
277
|
+
*
|
|
278
|
+
* This **must** be called before the first request triggers lazy
|
|
279
|
+
* initialisation of the better-auth instance — typically from a
|
|
280
|
+
* `kernel:ready` hook where the actual server port is known.
|
|
281
|
+
*
|
|
282
|
+
* If the auth instance has already been created this is a no-op and
|
|
283
|
+
* a warning is emitted.
|
|
284
|
+
*/
|
|
285
|
+
setRuntimeBaseUrl(url: string): void {
|
|
286
|
+
if (this.auth) {
|
|
287
|
+
console.warn(
|
|
288
|
+
'[AuthManager] setRuntimeBaseUrl() called after the auth instance was already created — ignoring. ' +
|
|
289
|
+
'Ensure this method is called before the first request.',
|
|
290
|
+
);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.config = { ...this.config, baseUrl: url };
|
|
294
|
+
}
|
|
295
|
+
|
|
227
296
|
/**
|
|
228
297
|
* Get the underlying better-auth instance
|
|
229
298
|
* Useful for advanced use cases
|
package/src/auth-plugin.test.ts
CHANGED
|
@@ -265,6 +265,95 @@ describe('AuthPlugin', () => {
|
|
|
265
265
|
// Should NOT throw
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
it('should auto-detect baseUrl from http-server port when port differs', async () => {
|
|
269
|
+
const mockRawApp = { all: vi.fn() };
|
|
270
|
+
const mockHttpServer = {
|
|
271
|
+
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
|
|
272
|
+
patch: vi.fn(), use: vi.fn(),
|
|
273
|
+
getRawApp: vi.fn(() => mockRawApp),
|
|
274
|
+
getPort: vi.fn(() => 3002),
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
mockContext.getService = vi.fn((name: string) => {
|
|
278
|
+
if (name === 'http-server') return mockHttpServer;
|
|
279
|
+
throw new Error(`Service not found: ${name}`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// AuthPlugin configured with default port 3000, but server will be on 3002
|
|
283
|
+
const registeredAuthManager = (mockContext.registerService as any).mock.calls[0][1];
|
|
284
|
+
const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
|
|
285
|
+
|
|
286
|
+
await authPlugin.start(mockContext);
|
|
287
|
+
await hookCapture.trigger('kernel:ready');
|
|
288
|
+
|
|
289
|
+
expect(setRuntimeSpy).toHaveBeenCalledWith('http://localhost:3002');
|
|
290
|
+
expect(mockContext.logger.info).toHaveBeenCalledWith(
|
|
291
|
+
expect.stringContaining('Auth baseUrl auto-updated to http://localhost:3002'),
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should NOT update baseUrl when port matches configured value', async () => {
|
|
296
|
+
const localHookCapture = createHookCapture();
|
|
297
|
+
const localPlugin = new AuthPlugin({
|
|
298
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
299
|
+
baseUrl: 'http://localhost:3000',
|
|
300
|
+
});
|
|
301
|
+
mockContext.hook = localHookCapture.hookFn;
|
|
302
|
+
await localPlugin.init(mockContext);
|
|
303
|
+
|
|
304
|
+
const mockRawApp = { all: vi.fn() };
|
|
305
|
+
const mockHttpServer = {
|
|
306
|
+
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
|
|
307
|
+
patch: vi.fn(), use: vi.fn(),
|
|
308
|
+
getRawApp: vi.fn(() => mockRawApp),
|
|
309
|
+
getPort: vi.fn(() => 3000),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
mockContext.getService = vi.fn((name: string) => {
|
|
313
|
+
if (name === 'http-server') return mockHttpServer;
|
|
314
|
+
throw new Error(`Service not found: ${name}`);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const registeredAuthManager = (mockContext.registerService as any).mock.calls.at(-1)[1];
|
|
318
|
+
const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
|
|
319
|
+
|
|
320
|
+
await localPlugin.start(mockContext);
|
|
321
|
+
await localHookCapture.trigger('kernel:ready');
|
|
322
|
+
|
|
323
|
+
expect(setRuntimeSpy).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should auto-detect baseUrl when no baseUrl configured (uses default fallback)', async () => {
|
|
327
|
+
const localHookCapture = createHookCapture();
|
|
328
|
+
// No baseUrl — defaults to http://localhost:3000 internally
|
|
329
|
+
const localPlugin = new AuthPlugin({
|
|
330
|
+
secret: 'test-secret-at-least-32-chars-long',
|
|
331
|
+
});
|
|
332
|
+
mockContext.hook = localHookCapture.hookFn;
|
|
333
|
+
await localPlugin.init(mockContext);
|
|
334
|
+
|
|
335
|
+
const mockRawApp = { all: vi.fn() };
|
|
336
|
+
const mockHttpServer = {
|
|
337
|
+
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
|
|
338
|
+
patch: vi.fn(), use: vi.fn(),
|
|
339
|
+
getRawApp: vi.fn(() => mockRawApp),
|
|
340
|
+
getPort: vi.fn(() => 3002),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
mockContext.getService = vi.fn((name: string) => {
|
|
344
|
+
if (name === 'http-server') return mockHttpServer;
|
|
345
|
+
throw new Error(`Service not found: ${name}`);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const registeredAuthManager = (mockContext.registerService as any).mock.calls.at(-1)[1];
|
|
349
|
+
const setRuntimeSpy = vi.spyOn(registeredAuthManager, 'setRuntimeBaseUrl');
|
|
350
|
+
|
|
351
|
+
await localPlugin.start(mockContext);
|
|
352
|
+
await localHookCapture.trigger('kernel:ready');
|
|
353
|
+
|
|
354
|
+
expect(setRuntimeSpy).toHaveBeenCalledWith('http://localhost:3002');
|
|
355
|
+
});
|
|
356
|
+
|
|
268
357
|
it('should throw error if auth not initialized', async () => {
|
|
269
358
|
const uninitializedPlugin = new AuthPlugin({
|
|
270
359
|
secret: 'test-secret',
|
package/src/auth-plugin.ts
CHANGED
|
@@ -114,6 +114,27 @@ export class AuthPlugin implements Plugin {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
if (httpServer) {
|
|
117
|
+
// Auto-detect the actual server URL when no explicit baseUrl was
|
|
118
|
+
// configured, or when the configured baseUrl uses a different port
|
|
119
|
+
// than the running server (e.g. port 3000 configured but 3002 bound).
|
|
120
|
+
// getPort() is optional on IHttpServer; duck-type check for it.
|
|
121
|
+
const serverWithPort = httpServer as IHttpServer & { getPort?: () => number };
|
|
122
|
+
if (this.authManager && typeof serverWithPort.getPort === 'function') {
|
|
123
|
+
const actualPort = serverWithPort.getPort();
|
|
124
|
+
if (actualPort) {
|
|
125
|
+
const configuredUrl = this.options.baseUrl || 'http://localhost:3000';
|
|
126
|
+
const configuredOrigin = new URL(configuredUrl).origin;
|
|
127
|
+
const actualUrl = `http://localhost:${actualPort}`;
|
|
128
|
+
|
|
129
|
+
if (configuredOrigin !== actualUrl) {
|
|
130
|
+
this.authManager.setRuntimeBaseUrl(actualUrl);
|
|
131
|
+
ctx.logger.info(
|
|
132
|
+
`Auth baseUrl auto-updated to ${actualUrl} (configured: ${configuredUrl})`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
117
138
|
// Route registration errors should propagate (server misconfiguration)
|
|
118
139
|
this.registerAuthRoutes(httpServer, ctx);
|
|
119
140
|
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
|