-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathauth-plugin.ts
More file actions
1809 lines (1692 loc) · 87.1 KB
/
Copy pathauth-plugin.ts
File metadata and controls
1809 lines (1692 loc) · 87.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
import type { BetterAuthOptions } from 'better-auth';
import { AuthConfig, type SocialProviderConfig, SystemObjectName, SystemUserId } from '@objectstack/spec/system';
import {
// ADR-0048 — the Setup/Studio/Account apps moved to their own packages
// (@objectstack/{setup,studio,account}); plugin-auth no longer registers them.
SystemOverviewDashboard,
SystemOverviewDatasets,
} from '@objectstack/platform-objects/apps';
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
import { resolveMultiOrgEnabled } from '@objectstack/types';
import {
AuthManager,
resolveOidcProviderEnabled,
readMcpServerEnabledEnv,
type AuthManagerOptions,
} from './auth-manager.js';
import { ensureDefaultOrganization } from './ensure-default-organization.js';
import { createTenancyService, type TenancyService } from './tenancy-service.js';
import { backfillMemberships, type MembershipPolicy } from './reconcile-membership.js';
import {
registerIdentityWriteGuard,
registerManagedUpdateWhitelist,
type SecondaryStorageLike,
} from './identity-write-guard.js';
import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js';
import { runSetInitialPassword } from './set-initial-password.js';
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
import { runResendVerificationEmail } from './send-verification-email.js';
import {
authIdentityObjects,
authPluginManifestHeader,
} from './manifest.js';
/**
* Auth Plugin Options
* Extends AuthConfig from spec with additional runtime options
*/
export interface AuthPluginOptions extends Partial<AuthConfig> {
/**
* ADR-0093 D1 — deployment membership policy. `'auto'` (default) auto-binds
* every new user to the single-org default org via the reconciler; invite-only
* deployments set `'invite-only'` to grant membership solely through explicit
* flows (invite / add-member / SSO JIT / host hooks).
* @default 'auto'
*/
membershipPolicy?: MembershipPolicy;
/**
* Whether to automatically register auth routes
* @default true
*/
registerRoutes?: boolean;
/**
* Base path for auth routes
* @default '/api/v1/auth'
*/
basePath?: string;
/**
* Override the datasource that owns the identity tables (sys_user,
* sys_session, …) when AuthPlugin's manifest is registered.
*
* Defaults to `'cloud'` (control-plane DB) so the historical
* single-tenant control-plane behaviour is preserved. Per-project
* kernels in objectos pass `'default'` so identity tables live in the
* project's own database — each project owns its own users.
*/
manifestDatasource?: string;
/**
* Application-specific organization roles to register with Better-Auth's
* organization plugin so invitations to those roles aren't rejected with
* ROLE_NOT_FOUND. Forwarded as-is to AuthManager. See
* {@link AuthManagerOptions.additionalOrgRoles} for details.
*/
additionalOrgRoles?: string[];
/**
* ADR-0081 D1 — single-org default-organization bootstrap. In single-org
* mode (`OS_MULTI_ORG_ENABLED` unset/false) nothing else ever creates an
* organization, so sessions carry no `activeOrganizationId` and better-auth
* `organization/invite-member` has no org to resolve — i.e. no way to add a
* user at all. When enabled (default), the plugin idempotently creates the
* `Default Organization` (slug `default`) and binds the first platform
* admin as `owner`, on `kernel:ready` and after every
* `sys_user_permission_set` insert. Inert in multi-org mode — the
* enterprise organizations package owns the bootstrap there.
* @default true
*/
autoDefaultOrganization?: boolean;
/**
* Pass-through to better-auth's `databaseHooks` option. Used by
* platform consumers (objectos kernel) to attach a
* `user.create.after` hook that auto-provisions a personal
* organization for JIT-created SSO users — better-auth's adapter
* bypasses kernel-level ObjectQL middleware, so this is the only
* hook point that fires for every user creation path (email signup,
* social/OIDC sign-in, admin-created accounts).
*/
databaseHooks?: BetterAuthOptions['databaseHooks'];
}
/**
* Authentication Plugin
*
* Provides authentication and identity services for ObjectStack applications.
*
* **Dual-Mode Operation:**
* - **Server mode** (HonoServerPlugin active): Registers HTTP routes at basePath,
* forwarding all auth requests to better-auth's universal handler.
* - **MSW/Mock mode** (no HTTP server): Gracefully skips route registration but
* still registers the `auth` service, allowing HttpDispatcher.handleAuth() to
* simulate auth flows (sign-up, sign-in, etc.) for development and testing.
*
* Features:
* - Session management
* - User registration/login
* - OAuth providers (Google, GitHub, etc.)
* - Organization/team support
* - 2FA, passkeys, magic links
*
* This plugin registers:
* - `auth` service (auth manager instance) — always
* - `app.com.objectstack.system` service (system object definitions) — always
* - HTTP routes for authentication endpoints — only when HTTP server is available
*
* Integrates with better-auth library to provide comprehensive
* authentication capabilities including email/password, OAuth, 2FA,
* magic links, passkeys, and organization support.
*/
export class AuthPlugin implements Plugin {
name = 'com.objectstack.auth';
type = 'standard';
version = '1.0.0';
dependencies: string[] = ['com.objectstack.engine.objectql']; // manifest service required
private options: AuthPluginOptions;
private authManager: AuthManager | null = null;
/** ADR-0093 D4 — the tenancy service registered in init(); reused at kernel:ready. */
private tenancy: TenancyService | null = null;
private configuredSocialProviders: SocialProviderConfig | undefined;
// ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or
// the kernel-cache adapter wired in init). The identity write guard's
// session-snapshot refresh reads through this; undefined = refresh no-ops.
private effectiveSecondaryStorage: AuthManagerOptions['secondaryStorage'];
constructor(options: AuthPluginOptions = {}) {
this.options = {
registerRoutes: true,
basePath: '/api/v1/auth',
...options
};
}
/**
* Open-source provider fallback: enable Google sign-in from conventional
* provider env vars when the application did not configure Google itself.
* Enterprise / product packages can contribute richer provider sets through
* the `auth:configure` hook below.
*/
private applyEnvSocialProviderFallbacks(config: AuthManagerOptions & AuthPluginOptions): void {
const env = (globalThis as any)?.process?.env as Record<string, string | undefined> | undefined;
if (String(env?.OS_AUTH_GOOGLE_ENABLED ?? 'true').toLowerCase() === 'false') return;
const googleClientId = env?.GOOGLE_CLIENT_ID;
const googleClientSecret = env?.GOOGLE_CLIENT_SECRET;
if (!googleClientId || !googleClientSecret) return;
const socialProviders = {
...(config.socialProviders ?? {}),
} as NonNullable<AuthPluginOptions['socialProviders']>;
if (!socialProviders.google) {
socialProviders.google = {
clientId: googleClientId,
clientSecret: googleClientSecret,
enabled: true,
};
config.socialProviders = socialProviders;
}
}
async init(ctx: PluginContext): Promise<void> {
ctx.logger.info('Initializing Auth Plugin...');
// Validate required configuration
if (!this.options.secret) {
throw new Error('AuthPlugin: secret is required');
}
// Get data engine service for database operations
const dataEngine = ctx.getService<any>('data');
if (!dataEngine) {
ctx.logger.warn('No data engine service found - auth will use in-memory storage');
}
const authConfig: AuthManagerOptions & AuthPluginOptions = {
...this.options,
dataEngine,
logger: ctx.logger,
// ADR-0093 D2/D3 — the membership reconciler consults the tenancy service
// (lazily, at hook-fire time — the service is registered below, after the
// `auth` service) to resolve the target org. membershipPolicy defaults to
// 'auto' in the reconciler.
getTenancy: () => {
try {
return ctx.getService<TenancyService>('tenancy');
} catch {
return undefined;
}
},
};
// ADR-0069 D2 — wire the kernel `cache` service as better-auth's shared
// secondaryStorage (rate-limit counters + session cache). Shared across
// nodes iff the cache service is (Redis adapter in a cluster; memory
// single-node). An explicit `secondaryStorage` on the options wins. Skipped
// when no cache service is registered — with a warning, because a multi-node
// deployment then silently rate-limits per-process (ADR-0069 D2 honesty).
if (!authConfig.secondaryStorage) {
// The `cache` service is registered ASYNC — `getService` throws for it,
// so resolve via `getServiceAsync` and treat any failure (not registered,
// or not yet ready) as "no shared cache".
let cache: any;
try {
cache = await (ctx as { getServiceAsync?: (n: string) => Promise<unknown> }).getServiceAsync?.('cache');
} catch {
cache = undefined;
}
if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') {
const { cacheSecondaryStorage } = await import('./secondary-storage.js');
authConfig.secondaryStorage = cacheSecondaryStorage(cache);
ctx.logger.info(
'[auth] rate-limit + session store bound to the kernel cache service — shared across nodes iff the cache is (ADR-0069 D2)',
);
} else {
ctx.logger.warn(
'[auth] no cache service registered — rate-limit counters use a per-process in-memory store; a multi-node deployment needs a shared cache (Redis) to enforce limits globally (ADR-0069 D2)',
);
}
}
this.applyEnvSocialProviderFallbacks(authConfig);
// Open extension point for packages that contribute auth providers
// (enterprise SSO, hosted control-plane SSO, etc.) without forking
// framework's AuthPlugin. Handlers mutate the draft config in place.
await ctx.trigger('auth:configure', authConfig, ctx);
this.configuredSocialProviders = authConfig.socialProviders
? { ...authConfig.socialProviders }
: undefined;
// Initialize auth manager with data engine
this.authManager = new AuthManager(authConfig);
// ADR-0092 D6 — remember the storage better-auth will actually use so the
// identity write guard can keep cached session snapshots coherent.
this.effectiveSecondaryStorage = authConfig.secondaryStorage;
// Register auth service
ctx.registerService('auth', this.authManager);
// ADR-0093 D4 — register the `tenancy` service (single source of truth for
// tenancy mode). Registered AFTER `auth` so `auth` stays the plugin's first
// service registration (consumers and tests rely on that ordering). Baseline
// derives `isolationActive` from the presence of the `org-scoping` service
// (registered by @objectstack/organizations when installed), so the
// enterprise package needs no change to light it up. `getService` is a cheap
// registry lookup and org-scoping registers AFTER plugin-auth, so the probe
// is deferred to first read (start()/request time).
const tenancy: TenancyService = createTenancyService({
requested: resolveMultiOrgEnabled(),
probeIsolation: () => {
try {
return !!ctx.getService('org-scoping');
} catch {
return false;
}
},
getEngine: () => {
try {
return ctx.getService('objectql');
} catch {
return undefined;
}
},
logger: ctx.logger,
});
ctx.registerService('tenancy', tenancy);
this.tenancy = tenancy;
ctx.getService<{ register(m: any): void }>('manifest').register({
...authPluginManifestHeader,
...(this.options.manifestDatasource
? { defaultDatasource: this.options.manifestDatasource }
: {}),
objects: authIdentityObjects,
// ADR-0048 — Setup/Studio/Account apps (and the Setup nav contributions)
// moved to their own one-app packages (@objectstack/{setup,studio,account}),
// each registering under its own package id so /apps/<packageId> resolves
// unambiguously. plugin-auth keeps only the auth objects + their pages.
// Slotted record-detail pages for system objects — currently
// sys_organization gets a Members / Invitations / Teams tab strip
// (see SysOrganizationDetailPage for the rationale and the
// intentionally-omitted OAuth / SSO tabs).
pages: [SysOrganizationDetailPage, SysUserDetailPage],
// List views for each Setup-nav object are defined on the schema
// itself via the canonical `listViews` map (e.g.
// sys_user.listViews.{all_users,unverified,two_factor}). Registering
// top-level views here is the legacy pre-M10.30c pattern — it caused
// duplicate "Users"/"Roles"/"Sessions" tabs to appear alongside the
// schema-derived ones, sometimes referencing nonexistent fields
// (e.g. legacy `users.view` had phone/status/active columns that do
// not exist on sys_user). Schema-embedded listViews is the single
// source of truth.
dashboards: [SystemOverviewDashboard],
// ADR-0021 — datasets backing the System Overview dashboard's widgets.
datasets: SystemOverviewDatasets,
// ADR-0024 / cloud#551 — surface "SSO Providers" (sys_sso_provider) in the
// Setup app's Access Control group, but ONLY when the external-IdP RP is
// wired (self-host `OS_SSO_ENABLED`, or the cloud per-env `planAllowsSso`
// arriving via `plugins.sso`). Without the gate the entry would render an
// empty list + a "Register" button whose endpoint 404s when SSO is off.
// Owning-plugin-contributes pattern (ADR-0029 K2), mirroring plugin-security.
...(this.authManager.isSsoWired()
? {
navigationContributions: [
{
app: 'setup',
group: 'group_access_control',
// After Roles/Permission-Sets (100) and Sharing (200), near API Keys (300).
priority: 250,
items: [
{
id: 'nav_sso_providers',
type: 'object',
label: 'SSO Providers',
objectName: 'sys_sso_provider',
icon: 'log-in',
requiredPermissions: ['manage_platform_settings'],
},
],
},
],
}
: {}),
});
ctx.logger.info('Auth Plugin initialized successfully');
}
async start(ctx: PluginContext): Promise<void> {
ctx.logger.info('Starting Auth Plugin...');
if (!this.authManager) {
throw new Error('Auth manager not initialized');
}
// Setup App translations are now loaded by `PlatformObjectsPlugin`
// (in @objectstack/platform-objects). Translation bundles belong with
// the package that defines them; auth-plugin no longer piggy-backs on
// its kernel:ready hook for this.
// Defer HTTP route registration to kernel:ready hook.
// This ensures all plugins (including HonoServerPlugin) have completed
// their init and start phases before we attempt to look up the
// http-server service — making AuthPlugin resilient to plugin
// loading order.
if (this.options.registerRoutes) {
ctx.hook('kernel:ready', async () => {
// Inject the email service if available so better-auth callbacks
// (sendResetPassword / sendVerificationEmail / sendInvitationEmail
// / sendMagicLink) can actually deliver mail. Resolved here on
// kernel:ready so EmailServicePlugin has had a chance to register.
if (this.authManager) {
await this.bindAuthSettings(ctx);
let emailSvc: any;
try { emailSvc = ctx.getService<any>('email'); } catch { emailSvc = undefined; }
if (emailSvc) {
this.authManager.setEmailService(emailSvc);
ctx.logger.info('Auth: email service wired (transactional mail enabled)');
} else {
// No email service. The verification / password-reset callbacks now
// THROW when invoked without a transport (so an explicit resend
// reports a real error rather than faking success). If verification
// is REQUIRED, that means every signup would be stuck — surface the
// misconfiguration loudly at boot instead of one failure per signup.
const requiresEmail = !!this.authManager.getPublicConfig?.()?.emailPassword?.requireEmailVerification;
if (requiresEmail) {
ctx.logger.error(
'Auth: email verification is REQUIRED but NO email service is registered — '
+ 'verification & password-reset emails will FAIL and new users will be locked '
+ 'out at sign-in. Register an email service (e.g. EmailServicePlugin + OS_EMAIL_*) '
+ 'or disable verification (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false).',
);
} else {
ctx.logger.info('Auth: no email service registered — transactional mail disabled');
}
}
// #2780 — inject the SMS service so the phoneNumber plugin's OTP
// callbacks (send-otp / password-reset OTP) and the import
// SMS-invite path can deliver. Same lazy-resolution contract as
// the email service: absent ⇒ OTP endpoints keep failing loudly
// (NOT_SUPPORTED) while phone+password sign-in still works.
let smsSvc: any;
try { smsSvc = ctx.getService<any>('sms'); } catch { smsSvc = undefined; }
if (smsSvc) {
this.authManager.setSmsService(smsSvc);
if (this.authManager.isPhoneNumberEnabled()) {
ctx.logger.info(
this.authManager.isPhoneOtpDeliverable()
? 'Auth: sms service wired (phone-number OTP sign-in / reset enabled)'
: 'Auth: sms service present but NOT configured with a real provider — phone-number OTP stays disabled in production',
);
}
} else if (this.authManager.isPhoneNumberEnabled()) {
ctx.logger.info('Auth: no sms service registered — phone-number OTP disabled (password sign-in only)');
}
// Bind the email brand name (`{{appName}}`) to the live
// `branding.workspace_name` setting so the admin UI can rename the
// product without a redeploy. Only an *explicitly set* value
// overrides the configured `appName` — when the operator hasn't
// customised it (resolver returns the manifest default), we clear
// the override so the deployment's `appName` (e.g. `OS_APP_NAME`)
// keeps precedence. Mirrors EmailServicePlugin's settings binding.
try {
const settings = ctx.getService<any>('settings');
if (settings && typeof settings.get === 'function') {
const applyBrand = async () => {
try {
const resolved = await settings.get('branding', 'workspace_name', {});
const explicit = resolved && resolved.source !== 'default'
? resolved.value
: undefined;
this.authManager?.setAppName(
typeof explicit === 'string' ? explicit : undefined,
);
} catch (err: any) {
ctx.logger.warn(
'Auth: failed to apply branding.workspace_name: ' + (err?.message ?? err),
);
}
};
await applyBrand();
if (typeof settings.subscribe === 'function') {
settings.subscribe('branding', () => {
void applyBrand();
});
ctx.logger.info('Auth: bound appName to settings namespace=branding');
}
// #2815 — bind the auth SMS locale to the deployment default
// (`localization.locale`) so OTP/invitation texts render in the
// workspace language. Live-rebinds on settings changes.
const applySmsLocale = async () => {
try {
const resolved = await settings.get('localization', 'locale', {});
const value = resolved?.value;
this.authManager?.setDefaultSmsLocale(
typeof value === 'string' ? value : undefined,
);
} catch (err: any) {
ctx.logger.warn(
'Auth: failed to apply localization.locale: ' + (err?.message ?? err),
);
}
};
await applySmsLocale();
if (typeof settings.subscribe === 'function') {
settings.subscribe('localization', () => {
void applySmsLocale();
});
}
}
} catch {
// settings service is optional — keep the configured appName.
}
// #2815 — seed the built-in bilingual auth SMS templates into
// sys_notification_template (insert-if-missing; tenant edits are
// never overwritten). Only meaningful when phone sign-in is on;
// the table may not exist yet on a fresh env (messaging provisions
// it at kernel:ready), so failures log-and-continue.
if (this.authManager.isPhoneNumberEnabled()) {
const engine = this.authManager.getDataEngine();
if (engine) {
const { seedPhoneSmsTemplates } = await import('./phone-sms-texts.js');
await seedPhoneSmsTemplates(engine, ctx.logger);
}
}
}
let httpServer: IHttpServer | null = null;
try {
httpServer = ctx.getService<IHttpServer>('http-server');
} catch {
// Service not found — expected in MSW/mock mode
}
if (httpServer) {
// Auto-detect the actual server URL when no explicit baseUrl was
// configured, or when the configured baseUrl uses a different port
// than the running server (e.g. port 3000 configured but 3002 bound).
// getPort() is optional on IHttpServer; duck-type check for it.
const serverWithPort = httpServer as IHttpServer & { getPort?: () => number };
if (this.authManager && typeof serverWithPort.getPort === 'function') {
const actualPort = serverWithPort.getPort();
if (actualPort) {
const configuredUrl = this.options.baseUrl || 'http://localhost:3000';
const configuredOrigin = new URL(configuredUrl).origin;
const actualUrl = `http://localhost:${actualPort}`;
// Only auto-correct the port when the configured URL is already a
// localhost URL (development mode). In production (Vercel/cloud) the
// configured baseUrl is the real public hostname — never overwrite it
// with a localhost URL, which would break OAuth callback URLs.
const configuredIsLocalhost = configuredOrigin.startsWith('http://localhost');
if (configuredIsLocalhost && configuredOrigin !== actualUrl) {
this.authManager.setRuntimeBaseUrl(actualUrl);
ctx.logger.info(
`Auth baseUrl auto-updated to ${actualUrl} (configured: ${configuredUrl})`,
);
}
}
}
// Route registration errors should propagate (server misconfiguration)
this.registerAuthRoutes(httpServer, ctx);
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
} else {
ctx.logger.warn(
'No HTTP server available — auth routes not registered. ' +
'Auth service is still available for MSW/mock environments via HttpDispatcher.'
);
}
});
}
// Dev-only: provision a known, loginable platform admin on an empty DB.
// Registered as its own kernel:ready hook (independent of registerRoutes)
// so it runs whenever the runtime boots in development.
ctx.hook('kernel:ready', async () => {
await this.maybeSeedDevAdmin(ctx);
});
// ADR-0081 D1 — single-org default-organization bootstrap. Multi-org
// keeps its existing owner (the enterprise organizations package, which
// runs the same idempotent helper with the seed-ownership step injected);
// one crisp owner per mode.
if (this.options.autoDefaultOrganization !== false && !resolveMultiOrgEnabled()) {
const runEnsure = async () => {
try {
const ql: any = ctx.getService<any>('objectql');
if (!ql) return;
const res = await ensureDefaultOrganization(ql, { logger: ctx.logger });
if (res.defaultOrgCreated) {
ctx.logger.info(
`[auth] created Default Organization ${res.defaultOrgId} for the platform admin (single-org)`,
);
}
} catch (e) {
ctx.logger.warn?.('[auth] ensureDefaultOrganization failed', {
error: (e as Error).message,
});
}
};
ctx.hook('kernel:ready', runEnsure);
// Re-run after every admin grant — covers the "first sign-up promoted
// to platform admin" case where kernel:ready fired before any user
// existed (same wiring the multi-org bootstrap uses).
try {
const ql: any = ctx.getService<any>('objectql');
if (ql && typeof ql.registerMiddleware === 'function') {
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
await next();
if (
opCtx?.object === 'sys_user_permission_set' &&
(opCtx?.operation === 'insert' || opCtx?.operation === 'create')
) {
await runEnsure();
}
});
}
} catch {
/* objectql optional in mock mode — the kernel:ready pass still runs */
}
}
// ADR-0093 D6 — backfill memberships for pre-existing member-less users
// (historical create-user / import rows from before the reconciler existed).
// Registered AFTER the default-org bootstrap hook so a target org exists by
// the time it runs. `backfillMemberships` self-guards: it no-ops under
// `invite-only` policy and in multi-org (tenancy.defaultOrgId() → null),
// where a wrong org guess would be a data-exposure bug, not a convenience.
// Opt out entirely via OS_SKIP_MEMBERSHIP_BACKFILL=1 (operators who curate
// memberships by hand).
if (String(process.env.OS_SKIP_MEMBERSHIP_BACKFILL ?? '').trim() !== '1') {
ctx.hook('kernel:ready', async () => {
try {
const ql: any = ctx.getService<any>('objectql');
const tenancy = this.tenancy;
if (!ql || !tenancy) return;
const res = await backfillMemberships(ql, {
policy: this.options.membershipPolicy ?? 'auto',
resolveTargetOrg: () => tenancy.defaultOrgId(),
logger: ctx.logger,
});
if (res.bound > 0) {
ctx.logger.info(
`[auth] membership backfill bound ${res.bound} pre-existing member-less user(s) to the default organization (ADR-0093 D6)`,
res,
);
}
} catch (e) {
ctx.logger.warn?.('[auth] membership backfill failed', {
error: (e as Error).message,
});
}
});
}
// Identity-source provenance for accounts created OUTSIDE better-auth's
// `databaseHooks` — @better-auth/scim creates `sys_account` at the adapter
// level, which BYPASSES `account.create.after` / `stampIdentitySource`. This
// ObjectQL `afterInsert` hook stamps `source=idp_provisioned` regardless of
// the creation path, so SCIM-provisioned users are correctly marked as the
// managed mirror (ADR-0024 D4 / ADR-0071 verification #1). It mirrors the
// federated branch of `stampIdentitySource`, is idempotent, and never breaks
// the insert. Complementary to (not a replacement for) the OAuth-path stamp.
ctx.hook('kernel:ready', async () => {
try {
// Use the kernel's ObjectQL engine (available + hookable at kernel:ready);
// the auth manager's getDataEngine() is not yet wired this early.
const engine: any = ctx.getService<any>('objectql');
if (!engine || typeof engine.registerHook !== 'function') return;
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
engine.registerHook('afterInsert', async (hookCtx: any) => {
try {
if (hookCtx?.object !== 'sys_account') return;
const acct: any = hookCtx.result ?? {};
const providerId = acct.provider_id ?? acct.providerId;
const userId = acct.user_id ?? acct.userId;
// Only federated/SCIM accounts mark the user managed; a local
// password (`credential`) keeps the user env-native.
if (!userId || !providerId || providerId === 'credential') return;
// QueryAST options use `where` (not `filter`); a wrong key is silently
// ignored and counts every row — the bug that shipped env_native.
const credCount = await engine.count('sys_account', {
where: { user_id: userId, provider_id: 'credential' }, context: SYSTEM_CTX,
});
if (typeof credCount === 'number' && credCount > 0) return;
const u = await engine.findOne('sys_user', {
where: { id: userId }, fields: ['id', 'source'], context: SYSTEM_CTX,
});
if (u && u.source !== 'idp_provisioned') {
await engine.update('sys_user', { id: userId, source: 'idp_provisioned' }, { context: SYSTEM_CTX });
}
} catch {
// Provenance must never break account creation.
}
}, { packageId: 'com.objectstack.plugin-auth' });
ctx.logger.info('Identity-source afterInsert stamp registered on sys_account (SCIM-safe)');
} catch {
// Engine not available — skip; OAuth path still stamps via databaseHooks.
}
});
// ADR-0092 D2/D6 — generic identity write guard. Every object whose
// schema declares `managedBy: 'better-auth'` gets fail-closed protection
// against USER-CONTEXT writes through the generic data path; the only
// opening is the per-object update whitelist (sys_user → profile fields).
// Internal writes (better-auth adapter, isSystem plugin/system contexts)
// bypass — see identity-write-guard.ts for the full contract.
ctx.hook('kernel:ready', async () => {
try {
const engine: any = ctx.getService<any>('objectql');
if (!engine || typeof engine.registerHook !== 'function') return;
registerManagedUpdateWhitelist(SystemObjectName.USER, SYS_USER_PROFILE_EDIT_FIELDS);
registerIdentityWriteGuard(engine, {
packageId: 'com.objectstack.plugin-auth.identity-write-guard',
logger: ctx.logger,
getSecondaryStorage: () =>
this.effectiveSecondaryStorage as SecondaryStorageLike | undefined,
});
} catch {
// Engine not available (mock mode) — permission-set defaults remain
// the only gate, exactly the pre-guard status quo.
}
});
// Register auth middleware on ObjectQL engine (if available)
try {
const ql = ctx.getService<any>('objectql');
if (ql && typeof ql.registerMiddleware === 'function') {
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
// If context already has userId or isSystem, skip auth resolution
if (opCtx.context?.userId || opCtx.context?.isSystem) {
return next();
}
// Future: resolve session from AsyncLocalStorage or request context
await next();
});
ctx.logger.info('Auth middleware registered on ObjectQL engine');
}
} catch (_e) {
ctx.logger.debug('ObjectQL engine not available, skipping auth middleware registration');
}
ctx.logger.info('Auth Plugin started successfully');
}
/**
* Bind the small open-source auth settings namespace to better-auth config.
*
* Only explicit settings values (stored or OS_AUTH_* env overrides) affect
* runtime config. Manifest defaults are UI defaults and do not mask code or
* deployment configuration.
*/
private async bindAuthSettings(ctx: PluginContext): Promise<void> {
if (!this.authManager) return;
let settings: any;
try {
settings = ctx.getService<any>('settings');
} catch {
return;
}
if (!settings || typeof settings.getNamespace !== 'function') return;
const applySettings = async (): Promise<void> => {
if (!this.authManager) return;
try {
const payload = await settings.getNamespace('auth');
const values: Record<string, unknown> = {};
const sources: Record<string, string | undefined> = {};
for (const [key, entry] of Object.entries(payload.values as Record<string, any>)) {
values[key] = entry?.value;
sources[key] = entry?.source;
}
const isExplicit = (key: string) => (sources[key] ?? 'default') !== 'default';
const asBoolean = (value: unknown, fallback: boolean): boolean => {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') return value.toLowerCase() !== 'false';
if (typeof value === 'number') return value !== 0;
return fallback;
};
const asTrimmedString = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
const asPositiveInt = (value: unknown): number | undefined => {
const n = Math.floor(Number(value));
return Number.isFinite(n) && n > 0 ? n : undefined;
};
const patch: Partial<AuthManagerOptions> = {};
const emailAndPassword: Partial<NonNullable<AuthConfig['emailAndPassword']>> = {};
if (isExplicit('email_password_enabled')) {
emailAndPassword.enabled = asBoolean(values.email_password_enabled, true);
}
if (isExplicit('signup_enabled')) {
emailAndPassword.disableSignUp = !asBoolean(values.signup_enabled, true);
}
if (isExplicit('require_email_verification')) {
emailAndPassword.requireEmailVerification = asBoolean(
values.require_email_verification,
false,
);
}
// Password policy — better-auth enforces these bounds on sign-up and
// password reset. Ignore malformed/non-positive values (keep the default).
if (isExplicit('password_min_length')) {
const n = asPositiveInt(values.password_min_length);
if (n !== undefined) emailAndPassword.minPasswordLength = n;
}
if (isExplicit('password_max_length')) {
const n = asPositiveInt(values.password_max_length);
if (n !== undefined) emailAndPassword.maxPasswordLength = n;
}
if (Object.keys(emailAndPassword).length > 0) {
patch.emailAndPassword = emailAndPassword as AuthManagerOptions['emailAndPassword'];
}
// Breached-password rejection (ADR-0069 D1) — enables better-auth's
// native `haveibeenpwned` plugin via the plugin-config gate. Default
// off; only an explicit toggle applies (manifest defaults must not
// mask the deployment env var). See buildPluginList() for the seam.
if (isExplicit('password_reject_breached')) {
patch.plugins = {
...(patch.plugins ?? {}),
passwordRejectBreached: asBoolean(values.password_reject_breached, false),
} as AuthManagerOptions['plugins'];
}
// Password complexity (ADR-0069 D1) — custom validator in the before
// hook (better-auth only enforces length). Only explicit values apply.
if (isExplicit('password_require_complexity')) {
patch.passwordRequireComplexity = asBoolean(values.password_require_complexity, false);
}
if (isExplicit('password_min_classes')) {
const n = asPositiveInt(values.password_min_classes);
if (n !== undefined) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
}
if (isExplicit('password_history_count')) {
// 0 disables → use a non-negative reader (asPositiveInt rejects 0).
const n = Math.floor(Number(values.password_history_count));
if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
}
if (isExplicit('password_expiry_days')) {
// 0 disables expiry → non-negative reader.
const n = Math.floor(Number(values.password_expiry_days));
if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n);
}
// Enforced MFA (ADR-0069 D3). Enabling it also turns the twoFactor
// plugin on so the /two-factor/* enrollment endpoints exist — otherwise
// gated users would have no way to comply.
if (isExplicit('mfa_required')) {
const on = asBoolean(values.mfa_required, false);
patch.mfaRequired = on;
if (on) {
patch.plugins = {
...(patch.plugins ?? {}),
twoFactor: true,
} as AuthManagerOptions['plugins'];
}
}
if (isExplicit('mfa_grace_period_days')) {
const n = Math.floor(Number(values.mfa_grace_period_days));
if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n);
}
// Session lifetime — days → seconds for better-auth's `session`
// (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold).
const session: { expiresIn?: number; updateAge?: number } = {};
if (isExplicit('session_expiry_days')) {
const d = asPositiveInt(values.session_expiry_days);
if (d !== undefined) session.expiresIn = d * 86_400;
}
if (isExplicit('session_refresh_days')) {
const d = asPositiveInt(values.session_refresh_days);
if (d !== undefined) session.updateAge = d * 86_400;
}
if (Object.keys(session).length > 0) {
patch.session = session as AuthManagerOptions['session'];
}
// Session controls (ADR-0069 D4) — idle / absolute / concurrent. 0 = off;
// non-negative reader so an explicit 0 disables.
const asNonNeg = (v: unknown): number | undefined => {
const n = Math.floor(Number(v));
return Number.isFinite(n) && n >= 0 ? n : undefined;
};
if (isExplicit('session_idle_timeout_minutes')) {
const n = asNonNeg(values.session_idle_timeout_minutes);
if (n !== undefined) patch.sessionIdleTimeoutMinutes = n;
}
if (isExplicit('session_absolute_max_hours')) {
const n = asNonNeg(values.session_absolute_max_hours);
if (n !== undefined) patch.sessionAbsoluteMaxHours = n;
}
if (isExplicit('max_concurrent_sessions_per_user')) {
const n = asNonNeg(values.max_concurrent_sessions_per_user);
if (n !== undefined) patch.maxConcurrentSessions = n;
}
// Network gating (ADR-0069 D5) — parse the CIDR/IP textarea into a list.
if (isExplicit('allowed_ip_ranges')) {
const raw = asTrimmedString(values.allowed_ip_ranges) ?? '';
patch.allowedIpRanges = raw
.split(/[\n,]+/)
.map((r) => r.trim())
.filter(Boolean);
}
// Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity)
// and rate-limit tuning (better-auth-native, per-IP). `asPositiveInt`
// rejects 0/malformed; lockout_threshold uses a non-negative reader so
// an explicit 0 can turn the feature off.
const asNonNegativeInt = (value: unknown): number | undefined => {
const n = Math.floor(Number(value));
return Number.isFinite(n) && n >= 0 ? n : undefined;
};
if (isExplicit('lockout_threshold')) {
const n = asNonNegativeInt(values.lockout_threshold);
if (n !== undefined) patch.lockoutThreshold = n;
}
if (isExplicit('lockout_duration_minutes')) {
const n = asPositiveInt(values.lockout_duration_minutes);
if (n !== undefined) patch.lockoutDurationMinutes = n;
}
if (isExplicit('rate_limit_max') || isExplicit('rate_limit_window_seconds')) {
const max = asPositiveInt(values.rate_limit_max) ?? 10;
const window = asPositiveInt(values.rate_limit_window_seconds) ?? 60;
// Tighten the auth-mutating endpoints; better-auth keeps its own
// defaults for everything else. customRules support `*` wildcards.
patch.rateLimit = {
enabled: true,
window,
max,
customRules: {
'/sign-in/email': { window, max },
'/sign-up/email': { window, max },
'/request-password-reset': { window, max },
'/reset-password': { window, max },
// #2780 — OTP endpoints cost an SMS per hit (pumping abuse);
// the per-number cooldown (otp-send-guard.ts) is always on,
// this adds the operator-tuned per-IP dimension. The plugin
// also ships its own /phone-number* default (10/min).
'/phone-number/send-otp': { window, max },
'/phone-number/request-password-reset': { window, max },
'/phone-number/verify': { window, max },
'/phone-number/reset-password': { window, max },
},
} as AuthManagerOptions['rateLimit'];
}
if (
isExplicit('google_enabled') ||
isExplicit('google_client_id') ||
isExplicit('google_client_secret')
) {
const socialProviders = {
...(this.configuredSocialProviders ?? {}),
} as NonNullable<SocialProviderConfig>;
const env = (globalThis as any)?.process?.env as Record<string, string | undefined> | undefined;
const googleEnabledFromEnv = env?.OS_AUTH_GOOGLE_ENABLED != null
? asBoolean(env.OS_AUTH_GOOGLE_ENABLED, true)
: undefined;
const googleClientId = asTrimmedString(values.google_client_id) ?? env?.GOOGLE_CLIENT_ID;
const googleClientSecret = asTrimmedString(values.google_client_secret) ?? env?.GOOGLE_CLIENT_SECRET;
if (googleEnabledFromEnv ?? (isExplicit('google_enabled') ? asBoolean(values.google_enabled, true) : true)) {
if (!socialProviders.google && googleClientId && googleClientSecret) {
socialProviders.google = {
clientId: googleClientId,
clientSecret: googleClientSecret,
enabled: true,
};
}
} else {
delete socialProviders.google;
}
patch.socialProviders = Object.keys(socialProviders).length > 0
? socialProviders
: undefined;
}
if (Object.keys(patch).length > 0) {
this.authManager.applyConfigPatch(patch);
}
} catch (err: any) {
ctx.logger.warn('Auth: failed to apply auth settings: ' + (err?.message ?? err));
}
};
await applySettings();
if (typeof settings.subscribe === 'function') {
settings.subscribe('auth', () => {
void applySettings();
});
ctx.logger.info('Auth: bound to settings namespace=auth');
}
}
async destroy(): Promise<void> {
// Cleanup if needed
this.authManager = null;
}
/**
* Dev-only admin bootstrap.
*
* On an EMPTY database (zero users), provision a well-known, loginable
* admin (admin@objectos.ai / admin123 by default) so backend debugging
* never blocks on a first-run sign-up wizard. The account is created
* through better-auth's real server-side `signUpEmail` pipeline (hashed
* credential + the same hooks the HTTP endpoint runs), so it is fully
* loginable; plugin-security's first-user middleware then promotes it to
* platform admin automatically.
*
* This replaces two earlier, divergent seeds:
* • the CLI-side HTTP seed (`os dev`), which POSTed the public sign-up
* endpoint from the parent process — racing server readiness and
* targeting a hard-coded port that broke under dev port auto-shift; and
* • plugin-dev's raw `sys_user` insert, which produced a credential-less,
* un-loginable row.
* Running it in-process needs no port and no readiness polling.
*
* Idempotent and non-destructive: it only ever acts on a zero-user DB and
* never touches an existing account, so a custom password is never
* overwritten.
*