-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathauth-plugin.ts
More file actions
964 lines (880 loc) · 43.4 KB
/
Copy pathauth-plugin.ts
File metadata and controls
964 lines (880 loc) · 43.4 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
// 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 { AuthManager, type AuthManagerOptions } from './auth-manager.js';
import { runSetInitialPassword } from './set-initial-password.js';
import {
authIdentityObjects,
authPluginManifestHeader,
} from './manifest.js';
/**
* Auth Plugin Options
* Extends AuthConfig from spec with additional runtime options
*/
export interface AuthPluginOptions extends Partial<AuthConfig> {
/**
* 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[];
/**
* 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;
private configuredSocialProviders: SocialProviderConfig | undefined;
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,
};
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);
// Register auth service
ctx.registerService('auth', this.authManager);
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,
});
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);
try {
const emailSvc = ctx.getService<any>('email');
if (emailSvc) {
this.authManager.setEmailService(emailSvc);
ctx.logger.info('Auth: email service wired (transactional mail enabled)');
}
} catch {
ctx.logger.info('Auth: no email service registered — auth callbacks will log instead of sending');
}
// 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');
}
}
} catch {
// settings service is optional — keep the configured appName.
}
}
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);
});
// 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'];
}
// 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'];
}
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.
*
* HARD-GATED to development (NODE_ENV==='development'): a known-credential
* admin can never be provisioned in production. Opt out within dev via
* OS_SEED_ADMIN=0 (or false/off/no).
*/
private async maybeSeedDevAdmin(ctx: PluginContext): Promise<void> {
if (process.env.NODE_ENV !== 'development') return;
const flag = String(process.env.OS_SEED_ADMIN ?? '').trim().toLowerCase();
if (['0', 'false', 'off', 'no'].includes(flag)) return;
const email = process.env.OS_SEED_ADMIN_EMAIL?.trim() || 'admin@objectos.ai';
const password = process.env.OS_SEED_ADMIN_PASSWORD?.trim() || 'admin123';
const name = process.env.OS_SEED_ADMIN_NAME?.trim() || 'Dev Admin';
let ql: any;
try { ql = ctx.getService<any>('objectql'); } catch { /* unavailable */ }
if (!ql || typeof ql.find !== 'function') return;
try {
// Only seed when no HUMAN user exists yet. A fresh DB still contains
// the system service account (SystemUserId.SYSTEM, role='system'),
// which must NOT count — mirror plugin-security's first-user detection
// so the seed fires on a genuinely empty DB. Any real human user (or a
// prior sign-up) disables the seed for good; we never touch or
// overwrite an existing account.
const rows = await ql
.find(SystemObjectName.USER, { where: {}, limit: 50 }, { context: { isSystem: true } })
.catch(() => []);
const humans = (Array.isArray(rows) ? rows : [])
.filter((u: any) => u && u.id !== SystemUserId.SYSTEM && u.role !== 'system');
if (humans.length > 0) {
ctx.logger.debug('[auth] dev admin seed skipped — a user already exists');
return;
}
if (!this.authManager) return;
const api: any = await this.authManager.getApi();
if (typeof api?.signUpEmail !== 'function') {
ctx.logger.warn('[auth] dev admin seed skipped — signUpEmail unavailable');
return;
}
// Real auth pipeline: creates sys_user + a hashed `credential` account
// and runs the sign-up hooks. The dev-mode OS_DISABLE_SIGNUP bypass
// (auth-manager.ts) lets this through on an empty DB even when sign-up
// is otherwise disabled.
await api.signUpEmail({ body: { email, password, name } });
ctx.logger.info(`🔑 Dev admin seeded: ${email} / ${password}`);
// Surface the credentials in the `serve` startup banner. The
// ctx.logger line above is swallowed by serve's boot-quiet window
// (the seed runs during runtime.start(), before stdout is restored),
// so the CLI reads this off the `auth` service and prints it after
// the banner instead.
this.authManager.devSeedResult = { email, password };
} catch (err: any) {
// Best-effort. The common benign case is a race where a real sign-up
// landed first (unique-email violation) — treat as "already seeded".
ctx.logger.warn(`[auth] dev admin seed skipped: ${err?.message ?? err}`);
}
}
/**
* Register authentication routes with HTTP server
*
* Uses better-auth's universal handler for all authentication requests.
* This forwards all requests under basePath to better-auth, which handles:
* - Email/password authentication
* - OAuth providers (Google, GitHub, etc.)
* - Session management
* - Password reset
* - Email verification
* - 2FA, passkeys, magic links (if enabled)
*/
private registerAuthRoutes(httpServer: IHttpServer, ctx: PluginContext): void {
if (!this.authManager) return;
const basePath = this.options.basePath || '/api/v1/auth';
// Get raw Hono app to use native wildcard routing
// Type assertion is safe here because we explicitly require Hono server as a dependency
if (!('getRawApp' in httpServer) || typeof (httpServer as any).getRawApp !== 'function') {
ctx.logger.error('HTTP server does not support getRawApp() - wildcard routing requires Hono server');
throw new Error(
'AuthPlugin requires HonoServerPlugin for wildcard routing support. ' +
'Please ensure HonoServerPlugin is loaded before AuthPlugin.'
);
}
const rawApp = (httpServer as any).getRawApp();
// Register /config before the wildcard so it takes precedence.
// better-auth has no /config endpoint, so without this explicit route
// the wildcard below forwards the request and better-auth returns 404.
rawApp.get(`${basePath}/config`, (c: any) => {
try {
const config = this.authManager!.getPublicConfig();
return c.json({ success: true, data: config });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return c.json({ success: false, error: { code: 'auth_config_error', message: err.message } }, 500);
}
});
// Bootstrap status — does an owner exist yet? Used by the Account SPA's
// root route to decide between rendering /login (normal flow) and
// /setup (first-run owner creation). Public, unauthenticated; only
// returns a boolean so it can be polled before the user has any
// credentials.
rawApp.get(`${basePath}/bootstrap-status`, async (c: any) => {
try {
const dataEngine = this.authManager!.getDataEngine();
if (!dataEngine) {
// No data engine wired (e.g. MSW/mock mode) — assume bootstrapped
// so the SPA falls through to its normal login flow.
return c.json({ hasOwner: true });
}
const count = await dataEngine.count('sys_user', {});
return c.json({ hasOwner: (count ?? 0) > 0 });
} catch (error) {
ctx.logger.warn('[AuthPlugin] bootstrap-status check failed; assuming bootstrapped', error as Error);
return c.json({ hasOwner: true });
}
});
// Device Authorization Grant (RFC 8628) endpoints — `/device/code`,
// `/device/token`, `/device/approve`, `/device/deny`, `/device` — are
// provided by better-auth's `device-authorization` plugin and reach
// the wildcard handler below. Enable via
// `AuthPluginConfig.deviceAuthorization`.
// Set an INITIAL local password for users who signed in via SSO and
// have no `credential` account yet. This is the "Set local password"
// affordance on a per-environment kernel — it lets a user that the
// platform onboarded via the objectstack-cloud OAuth provider sign in
// with email/password against this environment going forward without
// needing the SSO round-trip. Requires a valid session (so we know
// which user is asking) and refuses if a credential already exists
// (the user should use better-auth's /change-password endpoint in
// that case so the current password is verified).
//
// The body is `runSetInitialPassword` (shared with the cloud
// AuthProxyPlugin) so both mount points wrap better-auth's server-only
// `auth.api.setPassword` identically — see set-initial-password.ts.
rawApp.post(`${basePath}/set-initial-password`, async (c: any) => {
try {
const authApi = await this.authManager!.getApi();
const { status, body } = await runSetInitialPassword(authApi as any, c.req.raw);
return c.json(body, status);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('[AuthPlugin] set-initial-password failed', err);
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
}
});
// ────────────────────────────────────────────────────────────────────
// OAuth admin: toggle the `disabled` flag on a registered OAuth client.
//
// Why this lives here (and not as a plain data-layer UPDATE on
// sys_oauth_application): better-auth 1.6.11's stock admin update
// endpoint (`/admin/oauth2/update-client`) does NOT accept `disabled`
// in its Zod body schema, so the field gets silently stripped before
// it reaches `updateClientEndpoint`. The column exists, the runtime
// honours it everywhere (introspect, token, authorize, public-client
// lookup), but no client-facing API can flip it.
//
// We close the gap by writing through better-auth's own adapter under
// the `/api/v1/auth/*` namespace so all OAuth-application mutations
// remain auth-routed (no generic data-layer bypass for the `oauth_client`
// model). When upstream adds `disabled` to `adminUpdateOAuthClient`'s
// schema, this route can be deleted and the sys_oauth_application
// action retargeted to the stock endpoint.
//
// Upstream tracking: https://github.com/better-auth/better-auth
rawApp.post(`${basePath}/admin/oauth2/toggle-disabled`, async (c: any) => {
try {
let body: any = {};
try { body = await c.req.json(); } catch { body = {}; }
const clientId: unknown = body?.client_id;
const disabled: unknown = body?.disabled;
if (typeof clientId !== 'string' || clientId.length === 0) {
return c.json({ success: false, error: { code: 'invalid_request', message: 'client_id is required' } }, 400);
}
if (typeof disabled !== 'boolean') {
return c.json({ success: false, error: { code: 'invalid_request', message: 'disabled must be a boolean' } }, 400);
}
const authApi = await this.authManager!.getApi();
const session = await authApi.getSession({ headers: c.req.raw.headers });
if (!session?.user?.id) {
return c.json({ success: false, error: { code: 'unauthorized', message: 'Sign in first' } }, 401);
}
// The customSession plugin synthesizes `user.role = 'admin'` for
// platform admins (admin_full_access permission set) and active-org
// owners/admins; anyone else is denied.
if ((session.user as any).role !== 'admin') {
return c.json({ success: false, error: { code: 'forbidden', message: 'Admin role required' } }, 403);
}
// Write through the same ObjectQL data engine that better-auth's
// adapter uses. We target the snake_case table name (`sys_oauth_application`,
// mapped from better-auth's internal `oauthClient` model via
// `auth-schema-config.ts`) because `$context.adapter`'s model-lookup
// helper does not see plugin-provided model names from outside
// better-auth's own endpoint invocation context. This is the same
// physical row the better-auth runtime reads at introspect / token
// / authorize time, so the toggle is fully honoured.
const dataEngine: any = this.authManager!.getDataEngine();
if (!dataEngine) {
return c.json({ success: false, error: { code: 'unavailable', message: 'Data engine unavailable' } }, 503);
}
const existing = await dataEngine.findOne('sys_oauth_application', {
where: { client_id: clientId },
});
if (!existing) {
return c.json({ success: false, error: { code: 'not_found', message: 'OAuth client not found' } }, 404);
}
const updated = await dataEngine.update('sys_oauth_application', {
id: existing.id,
disabled,
updated_at: new Date(Math.floor(Date.now() / 1000) * 1000),
});
if (!updated) {
return c.json({ success: false, error: { code: 'internal', message: 'Unable to update OAuth client' } }, 500);
}
return c.json({
success: true,
data: {
client_id: clientId,
disabled,
},
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('[AuthPlugin] toggle-disabled failed', err);
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
}
});
// ────────────────────────────────────────────────────────────────────
// OAuth self-service: register an OAuth application for the signed-in
// user. Thin wrapper over better-auth's `/oauth2/create-client`
// endpoint (session-required, auto-stamps `user_id` from the session).
//
// Why this wrapper exists: the Account-app action surfaces a
// user-friendly textarea for "Redirect URLs" (one per line), but
// better-auth's Zod body schema requires `redirect_uris: string[]`.
// The metadata-driven action runner POSTs param values verbatim, so
// without a translation layer the upstream call fails validation with
// `Invalid input: expected array, received string`. We split the
// textarea on newlines, trim, drop empties, and forward to
// `createOAuthClient` so the row gets persisted with the caller's
// user_id and shows up in the `mine` listView.
//
// Upstream alternative would be enabling `allowDynamicClientRegistration`
// on `/oauth2/register`, but DCR has additional security implications
// (rate limiting, scope restriction) we don't want to enable broadly
// just to fix UX. Keeping the wrapper scoped to the self-service flow.
rawApp.post(`${basePath}/sys-oauth-application/register`, async (c: any) => {
try {
let body: any = {};
try { body = await c.req.json(); } catch { body = {}; }
const name: unknown = body?.name;
const redirectUrlsInput: unknown = body?.redirectURLs;
const type: unknown = body?.type;
if (typeof name !== 'string' || name.trim().length === 0) {
return c.json({ success: false, error: { code: 'invalid_request', message: 'name is required' } }, 400);
}
if (typeof redirectUrlsInput !== 'string' || redirectUrlsInput.trim().length === 0) {
return c.json({ success: false, error: { code: 'invalid_request', message: 'redirectURLs is required' } }, 400);
}
const redirectUris = redirectUrlsInput
.split(/[\r\n]+/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (redirectUris.length === 0) {
return c.json({ success: false, error: { code: 'invalid_request', message: 'redirectURLs must contain at least one URL' } }, 400);
}
const allowedTypes = new Set(['web', 'native', 'user-agent-based']);
const safeType = typeof type === 'string' && allowedTypes.has(type) ? type : 'web';
const authApi: any = await this.authManager!.getApi();
if (!authApi?.createOAuthClient) {
return c.json({ success: false, error: { code: 'unavailable', message: 'OIDC provider is not enabled on this environment' } }, 503);
}
// Forward request headers so better-auth can resolve the caller's
// session (sessionMiddleware on /oauth2/create-client). Without
// the session the row would lack `user_id` and never appear in
// the My Applications view.
let result: any;
try {
result = await authApi.createOAuthClient({
body: {
client_name: name.trim(),
redirect_uris: redirectUris,
type: safeType,
},
headers: c.req.raw.headers,
});
} catch (err: any) {
const status = typeof err?.status === 'number' ? err.status : 500;
const code = err?.body?.error ?? 'oauth_register_failed';
const message = err?.body?.error_description ?? err?.message ?? 'Unable to register OAuth client';
return c.json({ success: false, error: { code, message } }, status);
}
// Mirror the response shape consumed by the action's resultDialog
// (`client.client_id`, `client.client_secret`).
return c.json({ success: true, data: { client: result } });
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('[AuthPlugin] sys-oauth-application/register failed', err);
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
}
});
// Register wildcard route to forward all auth requests to better-auth.
// better-auth is configured with basePath matching our route prefix, so we
// forward the original request directly — no path rewriting needed.
rawApp.all(`${basePath}/*`, async (c: any) => {
try {
// Forward the original request to better-auth handler
const response = await this.authManager!.handleRequest(c.req.raw);
// better-auth catches internal errors and returns error Responses
// without throwing, so the catch block below would never trigger.
// We proactively log server errors here for observability.
if (response.status >= 500) {
try {
const body = await response.clone().text();
ctx.logger.error('[AuthPlugin] better-auth returned server error', new Error(`HTTP ${response.status}: ${body}`));
} catch {
ctx.logger.error('[AuthPlugin] better-auth returned server error', new Error(`HTTP ${response.status}: (unable to read body)`));
}
}
// Public-cache JWKS: it's static JSON that only changes when the
// signing key rotates (default ~30 days). better-auth doesn't set
// any Cache-Control header, so every relying party currently
// re-fetches it on every JWT verification (≈700 ms warm against a
// Container DO + Neon). Add a conservative public cache so CF's
// edge can short-circuit repeated fetches. The 5-min freshness +
// 24 h SWR window is well inside better-auth's default rotation
// and matches what most IdPs publish (Auth0, Cognito, Google).
try {
const url = c.req.url as string;
if (response.ok && /\/jwks(\?|$)/.test(url)) {
const existing = response.headers.get('cache-control');
if (!existing) {
response.headers.set(
'cache-control',
'public, max-age=300, stale-while-revalidate=86400',
);
}
}
} catch { /* best-effort header annotation */ }
return response;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('Auth request error:', err);
// Return error response
return new Response(
JSON.stringify({
success: false,
error: err.message,
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
});
// OIDC / OAuth 2.0 Authorization Server Metadata (RFC 8414) and
// OpenID Connect Discovery 1.0 require the well-known documents to be
// served from the **root** of the issuer URL — not under our auth
// basePath. `@better-auth/oauth-provider` ships dedicated helpers for
// this case (`oauthProviderAuthServerMetadata` /
// `oauthProviderOpenIdConfigMetadata`) which we mount here so external
// OIDC clients can discover the IdP at the canonical paths.
//
// Honour the same `OS_OIDC_PROVIDER_ENABLED` env-var override that
// `AuthManager.buildPlugins()` uses — without this check the
// discovery routes would NOT mount when an operator flipped the
// env var on without editing the config file, leaving external
// OIDC clients unable to discover the IdP.
const oidcEnv = (globalThis as any)?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === 'true' : undefined;
const oidcEnabled = oidcFromEnv ?? this.options.plugins?.oidcProvider ?? false;
if (oidcEnabled) {
void this.registerOidcDiscoveryRoutes(rawApp, ctx).catch((error) => {
ctx.logger.error('Failed to register OIDC discovery routes', error as Error);
});
}
ctx.logger.info(`Auth routes registered: All requests under ${basePath}/* forwarded to better-auth`);
}
/**
* Mount the OIDC / OAuth 2.0 well-known discovery documents at the root
* URL. Required by RFC 8414 §3 and OpenID Connect Discovery 1.0 §4 — the
* documents must live at `/.well-known/{oauth-authorization-server,openid-configuration}`
* relative to the issuer, not under the auth basePath.
*/
private async registerOidcDiscoveryRoutes(rawApp: any, ctx: PluginContext): Promise<void> {
const auth = await this.authManager!.getAuthInstance();
const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import(
'@better-auth/oauth-provider'
);
const authServerHandler = oauthProviderAuthServerMetadata(auth as any);
const openidConfigHandler = oauthProviderOpenIdConfigMetadata(auth as any);
// Cache-Control for OIDC discovery docs. These describe stable issuer
// configuration (endpoints, supported scopes, signing algs); they
// change only on app redeploy. CF edge can short-circuit repeated
// fetches and dramatically cut SSO first-call latency.
const DISCOVERY_CACHE = 'public, max-age=300, stale-while-revalidate=86400';
const withDiscoveryCache = async (handler: (req: Request) => Promise<Response> | Response, req: Request): Promise<Response> => {
const resp = await handler(req);
try {
if (resp.ok && !resp.headers.get('cache-control')) {
resp.headers.set('cache-control', DISCOVERY_CACHE);
}
} catch { /* best-effort */ }
return resp;
};
rawApp.get('/.well-known/oauth-authorization-server', (c: any) => withDiscoveryCache(authServerHandler, c.req.raw));
rawApp.get('/.well-known/openid-configuration', (c: any) => withDiscoveryCache(openidConfigHandler, c.req.raw));
ctx.logger.info(
'OIDC discovery endpoints mounted at /.well-known/{oauth-authorization-server,openid-configuration}',
);
}
}