-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathauth-manager.ts
More file actions
3742 lines (3545 loc) · 171 KB
/
Copy pathauth-manager.ts
File metadata and controls
3742 lines (3545 loc) · 171 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 type { Auth, BetterAuthOptions } from 'better-auth';
// better-auth value imports (betterAuth + plugins) are deferred via dynamic
// import() in getOrCreateAuth() / buildPluginList() so that disabled plugins
// never get loaded into the process. See Stage 2F (RSS investigation).
import type {
AuthConfig,
EmailAndPasswordConfig,
AuthPluginConfig,
OidcProvidersConfig,
} from '@objectstack/spec/system';
import type { IDataEngine } from '@objectstack/core';
import type { IEmailService, ISmsService } from '@objectstack/spec/contracts';
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types';
import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
import { isPlaceholderEmail } from './placeholder-email.js';
import { reconcileMembership, type MembershipPolicy } from './reconcile-membership.js';
import type { TenancyService } from './tenancy-service.js';
import { OtpSendGuard } from './otp-send-guard.js';
import {
PHONE_SMS_TOPICS,
builtinPhoneSmsBody,
interpolatePhoneSms,
loadPhoneSmsTemplateBody,
} from './phone-sms-texts.js';
import {
AUTH_USER_CONFIG,
AUTH_SESSION_CONFIG,
AUTH_ACCOUNT_CONFIG,
AUTH_VERIFICATION_CONFIG,
buildOrganizationPluginSchema,
buildTwoFactorPluginSchema,
buildOauthProviderPluginSchema,
buildDeviceAuthorizationPluginSchema,
buildJwtPluginSchema,
buildAdminPluginSchema,
buildPhoneNumberPluginSchema,
} from './auth-schema-config.js';
/**
* Detect WebContainer (StackBlitz) environment.
*
* WebContainer reports itself as Node.js but runs inside a browser. Several
* Node APIs are polyfilled with significant behavioural differences — most
* notably `node:async_hooks.AsyncLocalStorage`, whose `run()` does NOT
* propagate the store across `await` boundaries the way Node's native
* implementation does.
*/
function isWebContainerRuntime(): boolean {
if (typeof globalThis === 'undefined') return false;
const proc = (globalThis as any).process;
return (
Boolean(proc?.versions?.webcontainer) ||
Boolean(proc?.env?.SHELL?.includes?.('jsh')) ||
Boolean(proc?.env?.STACKBLITZ)
);
}
/**
* Synchronous AsyncLocalStorage polyfill compatible with better-auth's
* `requestStateAsyncStorage` slot.
*
* Behaviour:
* - `run(store, fn)` sets the current store synchronously before invoking
* `fn` and restores the previous store after `fn` (and any promise it
* returns) settles.
* - `getStore()` returns the current store.
*
* Why a polyfill is needed in WebContainer:
* - WebContainer's `node:async_hooks` does not propagate ALS context through
* `await`, so better-auth's `runWithRequestState(map, () => handler(req))`
* wrap loses the store as soon as the call chain awaits anything (e.g.
* the inner `customSession` → `getSession()` call). All endpoints that
* read request-state via `defineRequestState()` then throw
* "No request state found".
*
* Single-flight caveat:
* - This polyfill is process-global, not async-context-local. In a real
* server it could leak state across concurrent requests. That risk is
* acceptable here because:
* 1) It is only installed when WebContainer is detected (dev / preview
* sandboxes that handle one request at a time).
* 2) Each request still wraps the entire handler in `runWithRequestState`
* with a fresh WeakMap, so the in-flight request always sees its own
* store as long as nothing else mutates the slot mid-flight.
*/
class WebContainerRequestStateAsyncLocalStorage<T> {
private current: T | undefined = undefined;
run<R>(store: T, fn: () => R): R {
const prev = this.current;
this.current = store;
try {
const result = fn() as unknown;
if (result && typeof (result as Promise<unknown>).then === 'function') {
return (result as Promise<unknown>).finally(() => {
this.current = prev;
}) as unknown as R;
}
this.current = prev;
return result as R;
} catch (err) {
this.current = prev;
throw err;
}
}
getStore(): T | undefined {
return this.current;
}
}
/**
* Pre-populate better-auth's global `requestStateAsyncStorage` slot with the
* synchronous polyfill when running inside WebContainer.
*
* Better-auth caches its AsyncLocalStorage instance on
* `globalThis[Symbol.for('better-auth:global')].context.requestStateAsyncStorage`
* the first time `ensureAsyncStorage()` runs (see
* `@better-auth/core/dist/context/request-state.mjs`). By seeding that slot
* BEFORE any better-auth code touches it, every call to
* `runWithRequestState` / `getCurrentRequestState` — including the
* `@better-auth/core` copy that `plugin-auth` imports directly and the copy
* bundled with `better-auth` itself — share the same working polyfill.
*
* Outside WebContainer this is a no-op so production deployments keep
* Node's native AsyncLocalStorage.
*/
function installWebContainerRequestStatePolyfill(): void {
if (!isWebContainerRuntime()) return;
const sym = Symbol.for('better-auth:global');
const g = globalThis as any;
if (!g[sym]) {
g[sym] = { version: '0.0.0-polyfill', epoch: 0, context: {} };
}
if (!g[sym].context) g[sym].context = {};
if (!g[sym].context.requestStateAsyncStorage) {
g[sym].context.requestStateAsyncStorage = new WebContainerRequestStateAsyncLocalStorage();
// eslint-disable-next-line no-console
console.warn(
'[AuthManager] WebContainer detected: installed synchronous request-state polyfill ' +
'(node:async_hooks AsyncLocalStorage does not propagate context across await in WebContainer).',
);
}
}
function readBooleanEnv(name: string, legacyName?: string): boolean | undefined {
const env = (globalThis as any)?.process?.env as Record<string, string | undefined> | undefined;
const raw = env?.[name] ?? (legacyName ? env?.[legacyName] : undefined);
if (raw == null) return undefined;
const normalized = String(raw).trim().toLowerCase();
return !['0', 'false', 'off', 'no'].includes(normalized);
}
function readDisableSignUpEnv(): boolean | undefined {
const signupEnabled = readBooleanEnv('OS_AUTH_SIGNUP_ENABLED');
if (signupEnabled != null) return !signupEnabled;
return readBooleanEnv('OS_DISABLE_SIGNUP');
}
/**
* SSO-only ("enforced") login mode from the deployment env. Self-host ops set
* `OS_AUTH_SSO_ONLY=true` to lock the team to the configured IdP (parity with
* the `OS_DISABLE_SIGNUP` self-host knob). The cloud runtime drives the same
* behaviour per-env via the `ssoOnlyMode` config field instead.
*/
function readSsoOnlyEnv(): boolean | undefined {
return readBooleanEnv('OS_AUTH_SSO_ONLY');
}
/**
* Whether this runtime serves the HTTP MCP surface (`/api/v1/mcp`).
* Delegates to the platform-wide decision point (`isMcpServerEnabled` in
* `@objectstack/types`): default ON, explicit `false` opts out — so the
* OAuth/DCR follow-defaults below track the surface they exist to serve.
*/
export function readMcpServerEnabledEnv(): boolean {
return isMcpServerEnabled();
}
/**
* SINGLE decision point for "is the embedded OAuth/OIDC authorization server
* on?" — shared by `buildPluginList()`, the `/auth/config` features block and
* the discovery-route mounting in `auth-plugin.ts`, so the wired plugin, the
* advertised feature flag and the `.well-known` documents can never disagree.
*
* Resolution order: `OS_OIDC_PROVIDER_ENABLED` env (operator override, wins)
* → config file → **on when the MCP server surface is enabled** (#2698: the
* MCP endpoint's human-client track is OAuth 2.1 and every deployment is its
* own authorization server, so enabling MCP without an AS would strand every
* OAuth-capable client on admin-minted API keys).
*/
export function resolveOidcProviderEnabled(pluginConfig?: Partial<AuthPluginConfig>): boolean {
return readBooleanEnv('OS_OIDC_PROVIDER_ENABLED') ?? pluginConfig?.oidcProvider ?? readMcpServerEnabledEnv();
}
/**
* Whether RFC 7591 Dynamic Client Registration is allowed on the embedded
* authorization server. `OS_OIDC_DCR_ENABLED` env wins, then the config
* field, then it FOLLOWS the MCP surface: DCR is what lets a generic MCP
* client self-register against any deployment (no central client registry
* exists), so it defaults on exactly when MCP is on.
*/
export function resolveDcrEnabled(pluginConfig?: Partial<AuthPluginConfig>): boolean {
return (
readBooleanEnv('OS_OIDC_DCR_ENABLED') ??
pluginConfig?.dynamicClientRegistration ??
readMcpServerEnabledEnv()
);
}
/**
* OAuth 2.1 §1.5 transport rule for the MCP OAuth track: authorization/token
* exchanges and bearer usage require TLS, with loopback exempt (dev). A
* plain-HTTP non-loopback deployment keeps the API-key track only — the
* OAuth surface (protected-resource metadata, bearer acceptance) stays dark,
* fail-closed, and is logged once at mount time.
*/
export function isOAuthEligibleBaseUrl(url: string): boolean {
try {
const u = new URL(url);
if (u.protocol === 'https:') return true;
if (u.protocol !== 'http:') return false;
const host = u.hostname.toLowerCase();
return (
host === 'localhost' ||
host === '127.0.0.1' ||
host === '[::1]' ||
host === '::1' ||
host.endsWith('.localhost')
);
} catch {
return false;
}
}
/**
* Extended options for AuthManager
*/
export interface AuthManagerOptions extends Partial<AuthConfig> {
/**
* Better-Auth instance (for advanced use cases)
* If not provided, one will be created from config
*/
authInstance?: Auth<any>;
/**
* ObjectQL Data Engine instance
* Required for database operations using ObjectQL instead of third-party ORMs
*/
dataEngine?: IDataEngine;
/**
* Optional callback invoked AFTER an organization is created via better-auth's
* `createOrganization` (the org-plugin `afterCreateOrganization` hook). Lets a
* host stack run org-creation side effects that core `databaseHooks` can't —
* better-auth's org-plugin models (`organization`/`member`) do NOT fire those.
* The cloud control plane uses it to provision an org's born-with production
* environment. Failure-isolated: org creation is never rolled back.
*/
onOrganizationCreated?: (data: {
organizationId: string;
userId?: string;
name?: string;
slug?: string;
}) => void | Promise<void>;
/**
* D5.1 — OIDC OP authorization gate (cloud-as-IdP app-assignment).
* When set, it is called for an AUTHENTICATED subject on
* `/oauth2/authorize` before an authorization code is issued, with the
* subject + the requesting `clientId`. Return `false` to DENY (no code).
* The cloud control plane uses it to require org-membership: a cloud user
* may only obtain a code for an env client (`project_<envId>`) of an org
* they belong to. Unset (open editions / self-host, where the OP is not a
* multi-tenant issuer) = allow. Host is expected to fail CLOSED on error.
*/
oidcAuthorizeGate?: (params: { userId: string; clientId: string }) => boolean | Promise<boolean>;
/**
* Base path for auth routes
* Forwarded to better-auth's basePath option so it can match incoming
* request URLs without manual path rewriting.
* @default '/api/v1/auth'
*/
basePath?: string;
/**
* OIDC / Generic OAuth2 providers for enterprise SSO.
* Each entry is passed to better-auth's genericOAuth plugin.
*/
oidcProviders?: OidcProvidersConfig;
/**
* Application-specific organization roles to register with Better-Auth's
* organization plugin. Each name becomes a valid role for invitations and
* member assignments without going through Better-Auth's default
* `owner|admin|member` whitelist.
*
* The ObjectStack SecurityPlugin handles real RBAC enforcement by matching
* these role names against `permission` metadata (PermissionSets / Profiles),
* so Better-Auth only needs to accept them as opaque strings. Each role is
* registered with the minimum access-control privileges (equivalent to
* Better-Auth's `member` role) so it cannot inadvertently grant org-level
* admin capabilities.
*
* Typical source: the union of `permission` metadata names that have
* declared names, collected from the loaded stack at CLI boot.
*
* @example ['sales_rep', 'sales_manager', 'service_agent']
*/
additionalOrgRoles?: string[];
/**
* Optional outbound email service used by better-auth callbacks
* (`sendResetPassword`, `sendVerificationEmail`, `sendInvitationEmail`,
* `sendMagicLink`). When omitted, those callbacks degrade to logging
* the action URL — keeping flows usable in pilots / local dev — but
* production deployments SHOULD wire one via `setEmailService()`.
*
* Resolved lazily through {@link AuthManager.getEmailService}; safe
* to set after construction. AuthPlugin wires this from the kernel
* service registry on `kernel:ready`.
*/
emailService?: IEmailService;
/**
* Optional outbound SMS service used by the phoneNumber plugin's OTP
* callbacks (`sendOTP`, `sendPasswordResetOTP`) and the import SMS-invite
* path (#2780). When omitted, `/phone-number/send-otp` fails loudly with
* NOT_SUPPORTED (the pre-SMS behaviour) instead of silently logging.
*
* Resolved lazily through {@link AuthManager.getSmsService}; safe to set
* after construction. AuthPlugin wires this from the kernel service
* registry (`sms`, see `@objectstack/service-sms`) on `kernel:ready`.
*/
smsService?: ISmsService;
/**
* #2780 — knobs for the phone-number OTP surface. All optional; the
* defaults are deliberately conservative because every OTP send costs
* real money (SMS pumping abuse — see otp-send-guard.ts).
*/
phoneOtp?: {
/** Per-number cooldown between sends, seconds. Default 60. `0` disables. */
cooldownSeconds?: number;
/** Per-number rolling-hour send cap. Default 5. `0` disables. */
maxPerHour?: number;
/** Wrong-code attempts before the OTP is invalidated (better-auth `allowedAttempts`). Default 3. */
allowedAttempts?: number;
/** OTP validity window, seconds (better-auth `expiresIn`). Default 300. */
expiresIn?: number;
/** OTP length (better-auth `otpLength`). Default 6. */
otpLength?: number;
};
/**
* Display name used by built-in auth email templates (`{{appName}}`
* placeholder). Defaults to `'ObjectStack'` when omitted.
*/
appName?: string;
/**
* ADR-0081 D1 — default active-org on session create. When enabled
* (default), a `session.create.before` hook stamps `activeOrganizationId`
* from the caller's `sys_member` row (owner-preferred) whenever the draft
* lacks one. A host-supplied `session.create.before` (see
* {@link databaseHooks}) chains FIRST and keeps precedence. Set `false`
* to restore the raw better-auth behaviour (sessions start org-less).
* @default true
*/
autoActiveOrganization?: boolean;
/**
* Pass-through to better-auth's `databaseHooks` option. better-auth fires
* these around its own adapter writes (e.g. when `genericOAuth` creates
* a JIT user during SSO login), which the kernel-level ObjectQL
* middleware does NOT observe — better-auth's adapter goes through
* `dataEngine` directly, bypassing the `ql.registerMiddleware` chain.
*
* The platform uses this to attach a `user.create.after` hook that
* auto-provisions a personal organization for every newly-created user
* (mirroring what SecurityPlugin's middleware does for direct
* ObjectQL inserts) so SSO-arriving users don't land on the empty
* "create organization" screen.
*/
databaseHooks?: BetterAuthOptions['databaseHooks'];
/**
* ADR-0093 D1/D2 — deployment membership policy for the reconciler composed
* into `user.create.after`. `'auto'` (default) binds every new member-less
* user to the single-org default org; `'invite-only'` never auto-binds
* (membership comes only from invite / add-member / SSO JIT / host hooks).
* @default 'auto'
*/
membershipPolicy?: MembershipPolicy;
/**
* ADR-0093 D3/D4 — accessor for the `tenancy` service, consulted by the
* membership reconciler to resolve the target org (single → default org;
* multi → none). A lazy accessor because the service is registered on the
* kernel after the AuthManager is constructed; the reconciler calls it at
* hook-fire time (well after boot). Omitted → the reconciler no-ops
* (no target org), preserving pre-ADR-0093 behavior.
*/
getTenancy?: () => TenancyService | undefined;
/**
* Optional structured logger (the kernel `ctx.logger`) for best-effort
* bookkeeping surfaces such as the ADR-0093 membership reconciler. Omitted →
* those surfaces run silently (they already fail closed to no-op).
*/
logger?: { info?: (msg: string, meta?: any) => void; warn?: (msg: string, meta?: any) => void };
/**
* ADR-0069 D2 — account lockout (anti-brute-force). After this many
* consecutive failed sign-ins the account is locked for
* {@link lockoutDurationMinutes}. `0` (default) disables lockout.
* Enforced per-identity in the `/sign-in/email` before/after hooks
* (survives IP rotation, unlike the per-IP {@link rateLimit}).
*/
lockoutThreshold?: number;
/** Minutes an account stays locked once the threshold is crossed. Default 15. */
lockoutDurationMinutes?: number;
/**
* ADR-0069 D1 — password complexity. When `passwordRequireComplexity` is on,
* a new password must contain at least `passwordMinClasses` (1-4) of the
* character classes upper / lower / digit / symbol. Enforced by a validator
* in the `/sign-up/email`, `/reset-password`, `/change-password` before hook
* (better-auth only enforces min/max length natively).
*/
passwordRequireComplexity?: boolean;
/** Minimum distinct character classes required (1-4). Default 3. */
passwordMinClasses?: number;
/**
* ADR-0069 D1 — password history depth. When > 0, a password change/reset is
* rejected if the new password matches the current or any of the last
* `passwordHistoryCount` hashes (`sys_account.previous_password_hashes`).
* Reuses better-auth's native hash/verify — no bespoke crypto.
*/
passwordHistoryCount?: number;
/**
* ADR-0069 D1 — password expiry (days). When > 0, an authenticated user whose
* `sys_user.password_changed_at` is older than this is gated out of protected
* resources (`PASSWORD_EXPIRED`) until they change their password. Computed in
* `customSession` (→ `user.authGate`) and enforced at the transport seam. 0 =
* off. A null `password_changed_at` never expires (existing users on upgrade).
*/
passwordExpiryDays?: number;
/**
* ADR-0069 D3 — enforced MFA. When true, an authenticated user without TOTP
* enrolled (`sys_user.two_factor_enabled`) is gated out of protected resources
* (`MFA_REQUIRED`) once their grace window elapses, until they enroll. Shares
* the `customSession` → `user.authGate` seam with password expiry.
*/
mfaRequired?: boolean;
/** Days a user may defer MFA enrollment before the hard block. Default 7. */
mfaGracePeriodDays?: number;
/**
* ADR-0069 D4 — session controls. Enforced in `customSession` (idle/absolute)
* and the sign-in hook (concurrent). 0 = off for each. A revoked session is
* expired in place (`sys_session.expires_at` past + `revoked_at`/`revoke_reason`)
* so better-auth returns no session on the next request (→ 401 → re-login).
*/
sessionIdleTimeoutMinutes?: number;
sessionAbsoluteMaxHours?: number;
maxConcurrentSessions?: number;
/**
* ADR-0069 D5 — network gating. When non-empty, auth requests (sign-in,
* session) from a client IP outside these CIDR / exact ranges are rejected
* with `IP_NOT_ALLOWED` at the auth-route middleware. Requires a trusted proxy
* to set `x-forwarded-for` / `cf-connecting-ip`; fails OPEN when the client IP
* can't be determined (so a missing proxy header is a no-op, not a lockout).
*/
allowedIpRanges?: string[];
/**
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
* for the auth endpoints (`/sign-in/email`, `/sign-up/email`,
* `/reset-password`). Multi-node deployments need a shared `storage`.
*/
rateLimit?: BetterAuthOptions['rateLimit'];
/**
* ADR-0069 D2 — shared KV store for cross-node state. When set, better-auth
* uses it for **rate-limit counters** (the manager also flips
* `rateLimit.storage` to `'secondary-storage'`) and session caching, so both
* are enforced against ONE store across every node — closing the multi-node
* rate-limit-bypass hole (each node otherwise counts independently). Wired by
* `AuthPlugin` from the kernel `cache` service (memory single-node, Redis in
* a cluster). Absent → better-auth keeps its per-process in-memory store.
*/
secondaryStorage?: BetterAuthOptions['secondaryStorage'];
}
/**
* Authentication Manager
*
* Wraps better-auth and provides authentication services for ObjectStack.
* Supports multiple authentication methods:
* - Email/password
* - OAuth providers (Google, GitHub, etc.)
* - Magic links
* - Two-factor authentication
* - Passkeys
* - Organization/teams
*/
/** ADR-0069 D5 — parse a dotted-quad IPv4 to a uint32, or null when not IPv4. */
function ipv4ToInt(ip: string): number | null {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim());
if (!m) return null;
const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
if (p.some((n) => n > 255)) return null;
return (((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]) >>> 0;
}
/** ADR-0069 D5 — does `ip` match `range` (IPv4 CIDR `a.b.c.d/n`, or exact IP)? */
export function ipMatchesRange(ip: string, range: string): boolean {
const r = (range || '').trim();
if (!r) return false;
if (r.includes('/')) {
const [base, bitsStr] = r.split('/');
const bits = Number(bitsStr);
const ipInt = ipv4ToInt(ip);
const baseInt = ipv4ToInt(base);
if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) {
return ip.trim() === base.trim(); // non-IPv4-CIDR → exact-match fallback
}
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
return ((ipInt & mask) >>> 0) === ((baseInt & mask) >>> 0);
}
return ip.trim() === r;
}
export class AuthManager {
private auth: Auth<any> | null = null;
private config: AuthManagerOptions;
// ADR-0069 — cached "does any org require MFA" flag (per-org tightening).
// Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap.
private _orgMfaCache: { value: boolean; at: number } = { value: false, at: 0 };
private _orgMfaRefreshing = false;
// #2766 V1 — cached "does any user have must_change_password set" flag, so
// the admin-issued temp-password gate activates without making
// isAuthGateActive() (and thus every request's extra session read) hot in
// deployments that never use the feature. Same lazy-TTL pattern as
// _orgMfaCache; primed synchronously by noteMustChangePasswordIssued() on
// the node that issues a flag (other nodes catch up within the TTL).
private _mustChangeCache: { value: boolean; at: number } = { value: false, at: 0 };
private _mustChangeRefreshing = false;
/**
* Result of the dev-only admin seed (set by `AuthPlugin.maybeSeedDevAdmin`
* when it provisions the well-known admin on an empty DB). The `serve`
* command reads this after boot to surface the credentials in the startup
* banner. Undefined when no seed ran (production, opt-out, or a DB that
* already had a user).
*/
public devSeedResult?: { email: string; password: string };
constructor(config: AuthManagerOptions) {
this.config = config;
// WebContainer (StackBlitz) compatibility — install a synchronous
// AsyncLocalStorage polyfill for better-auth's request-state global
// BEFORE better-auth ever instantiates its own. See the helper for the
// full rationale.
installWebContainerRequestStatePolyfill();
// Use provided auth instance
if (config.authInstance) {
this.auth = config.authInstance;
}
// Don't create auth instance automatically to avoid database initialization errors
// It will be created lazily when needed
}
/**
* Get or create the better-auth instance (lazy initialization)
*/
private async getOrCreateAuth(): Promise<Auth<any>> {
if (!this.auth) {
this.auth = await this.createAuthInstance();
}
return this.auth;
}
/**
* Create a better-auth instance from configuration
*/
private async createAuthInstance(): Promise<Auth<any>> {
const { betterAuth } = await import('better-auth');
const { createAuthMiddleware } = await import('better-auth/api');
const plugins = await this.buildPluginList();
const passwordHasher = await this.resolvePasswordHasher();
const betterAuthConfig: BetterAuthOptions = {
// Base configuration
secret: this.config.secret || this.generateSecret(),
// Absolute origin (getCanonicalOrigin prepends https:// when baseUrl is a
// bare host) so the reset-password / verify-email / magic-link URLs
// better-auth derives from baseURL are always clickable links.
baseURL: this.getCanonicalOrigin(),
basePath: this.config.basePath || '/api/v1/auth',
// Database adapter configuration
database: this.createDatabaseConfig(),
// Model/field mapping: camelCase (better-auth) → snake_case (ObjectStack)
// These declarations tell better-auth the actual table/column names used
// by ObjectStack's protocol layer, enabling automatic transformation via
// createAdapterFactory.
user: {
...AUTH_USER_CONFIG,
// NOTE: the env-side AI-seat marker `sys_user.ai_access` is deliberately
// NOT declared as a better-auth additionalField. sys_user is a
// better-auth-MANAGED table and better-auth SELECTs explicit columns, so
// declaring it here would make getSession query a column that may not
// exist on every env yet → broken auth. Instead the column is owned by
// the objectql `SysUser` object def (provisioned by boot schema-sync)
// and read by a GUARDED system query in resolveCtx (can only no-op,
// never break auth). better-auth stays oblivious to the extra column.
},
account: {
...AUTH_ACCOUNT_CONFIG,
// Allow OIDC/OAuth callbacks to implicitly link the incoming
// identity to a pre-existing local user when the emails match.
//
// ObjectStack's platform SSO ("objectstack-cloud" provider) is the
// canonical case: cloud is the IdP for every project, so a user
// arriving via SSO is — by construction — the same person who was
// auto-seeded as the project owner when the project was created.
// Without trusting the provider, better-auth's safety check rejects
// the link with `error=account_not_linked` because the seeded user
// row has `emailVerified=false` (no actual verification ever runs
// in the IdP-mediated flow). See packages/plugins/plugin-auth/
// node_modules/better-auth/dist/oauth2/link-account.mjs:22.
//
// Custom-deployment consumers can extend the trusted set via
// `config.account.accountLinking.trustedProviders`; we always
// include `objectstack-cloud` because it is the platform IdP.
accountLinking: {
enabled: true,
// better-auth's account-linking gate has TWO independent clauses
// (see link-account.mjs:22). Trusting the provider only satisfies
// the first clause; the second — `requireLocalEmailVerified &&
// !dbUser.user.emailVerified` — still blocks linking when the
// pre-existing local user row has `emailVerified=false` (the
// default for owner-seeded rows). Disabling the local-email gate
// is safe here because the OAuth side is what we actually trust:
// the incoming identity was verified by the IdP. Consumers who
// need the stricter behavior can override via config.
requireLocalEmailVerified: false,
...((this.config as any)?.account?.accountLinking ?? {}),
trustedProviders: Array.from(new Set([
'objectstack-cloud',
...((this.config as any)?.account?.accountLinking?.trustedProviders ?? []),
])),
},
},
verification: {
...AUTH_VERIFICATION_CONFIG,
},
// Social / OAuth providers
...(this.config.socialProviders ? { socialProviders: this.config.socialProviders as any } : {}),
// Email and password configuration.
// `disableSignUp`: env overrides config/settings so deployments can
// lock the registration policy without relying on UI state.
emailAndPassword: (() => {
const disableSignUpFromEnv = readDisableSignUpEnv();
// SSO-only ("enforced") forces self-registration off (the managed team
// signs in via the IdP). `enabled` stays true so the break-glass
// password endpoint keeps working for the env owner / local admin.
const effectiveDisableSignUp = this.resolveSsoOnly()
? true
: (disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp);
return {
enabled: this.config.emailAndPassword?.enabled ?? true,
...(passwordHasher ? { password: passwordHasher } : {}),
...(effectiveDisableSignUp != null
? { disableSignUp: effectiveDisableSignUp } : {}),
...(this.config.emailAndPassword?.requireEmailVerification != null
? { requireEmailVerification: this.config.emailAndPassword.requireEmailVerification } : {}),
...(this.config.emailAndPassword?.minPasswordLength != null
? { minPasswordLength: this.config.emailAndPassword.minPasswordLength } : {}),
...(this.config.emailAndPassword?.maxPasswordLength != null
? { maxPasswordLength: this.config.emailAndPassword.maxPasswordLength } : {}),
...(this.config.emailAndPassword?.resetPasswordTokenExpiresIn != null
? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {}),
...(this.config.emailAndPassword?.autoSignIn != null
? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {}),
...(this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null
? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}),
sendResetPassword: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => {
// #2766 V1.5 — placeholder addresses (phone-only users) are never
// real recipients. Refuse loudly instead of "sending" into the void;
// the reset path for these users is phone sign-in / an admin
// set-user-password, not email.
if (isPlaceholderEmail(user.email)) {
throw new Error(
`Password-reset email refused: ${user.email} is a placeholder address (PLACEHOLDER_EMAIL). ` +
'This account has no real mailbox — use an admin password reset instead.',
);
}
const email = this.getEmailService();
if (!email) {
// No transport wired but password reset is enabled — a
// misconfiguration. THROW (don't silently drop): better-auth
// invokes this via `runInBackgroundOrAwait` and the forget-password
// route always returns `{status:true}`, so this never leaks whether
// an address exists AND never turns the request into a 500 — it just
// surfaces the failure in the logs instead of vanishing.
throw new Error(
`Password-reset email could not be sent to ${user.email}: no email service is configured for this deployment.`,
);
}
const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
// Surface both template-resolution throws and transport failures
// (status:'failed'); resilience is preserved by better-auth's
// background-task handling (see sendVerificationEmail) and the
// forget-password route always returns {status:true}, so this never
// leaks whether an address exists nor turns the request into a 500.
const result = await email.sendTemplate({
template: 'auth.password_reset',
to: { address: user.email, ...(user.name ? { name: user.name } : {}) },
data: {
user: { name: user.name || user.email, email: user.email, id: user.id },
resetUrl: url,
token,
expiresInMinutes: Math.round(ttlSec / 60),
appName: this.getAppName(),
},
relatedObject: 'sys_user',
relatedId: user.id,
});
if (result?.status === 'failed') {
throw new Error(
`Password-reset email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`,
);
}
},
};
})(),
// Email verification
...(this.config.emailVerification || this.config.emailService ? {
emailVerification: {
...(this.config.emailVerification?.sendOnSignUp != null
? { sendOnSignUp: this.config.emailVerification.sendOnSignUp } : {}),
...(this.config.emailVerification?.sendOnSignIn != null
? { sendOnSignIn: this.config.emailVerification.sendOnSignIn } : {}),
...(this.config.emailVerification?.autoSignInAfterVerification != null
? { autoSignInAfterVerification: this.config.emailVerification.autoSignInAfterVerification } : {}),
...(this.config.emailVerification?.expiresIn != null
? { expiresIn: this.config.emailVerification.expiresIn } : {}),
sendVerificationEmail: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => {
const email = this.getEmailService();
if (!email) {
// Verification is enabled (this callback only exists when it is)
// but no email transport is wired — a misconfiguration, not a
// transient blip. THROW so the explicit `/send-verification-email`
// resend endpoint (which awaits this) surfaces a real error
// instead of a false "email sent" success. Sign-up stays
// resilient regardless: better-auth runs the sendOnSignUp call
// through `runInBackgroundOrAwait`, which logs (never rethrows)
// a failure, so the account is still created and the user lands
// on the verify screen (where an honest resend now reports the
// problem). Previously this was swallowed, leaving every user
// permanently stuck with no signal and no resend that could work.
throw new Error(
`Verification email could not be sent to ${user.email}: no email service is configured for this deployment.`,
);
}
const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
// Let send failures propagate (see above): sendTemplate THROWS on
// template/loader errors, and returns status:'failed' on transport
// errors — surface both so resend is honest and signup stays
// resilient via better-auth's background-task error handling.
const result = await email.sendTemplate({
template: 'auth.verify_email',
to: { address: user.email, ...(user.name ? { name: user.name } : {}) },
data: {
user: { name: user.name || user.email, email: user.email, id: user.id },
verificationUrl: url,
token,
expiresInMinutes: Math.round(ttlSec / 60),
appName: this.getAppName(),
},
relatedObject: 'sys_user',
relatedId: user.id,
});
if (result?.status === 'failed') {
throw new Error(
`Verification email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`,
);
}
},
},
} : {}),
// Session configuration
session: {
...AUTH_SESSION_CONFIG,
expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default
updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default
},
// ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
// so better-auth keeps its own defaults otherwise. The settings bind
// supplies stricter `customRules` for the auth endpoints. When a shared
// secondaryStorage is wired, flip the rate-limit store to it so counters
// are enforced across nodes (default 'memory' is per-process).
...(this.config.rateLimit || this.config.secondaryStorage
? {
rateLimit: {
...(this.config.rateLimit ?? {}),
...(this.config.secondaryStorage ? { storage: 'secondary-storage' as const } : {}),
},
}
: {}),
// ADR-0069 D2 — shared KV for cross-node rate-limit + session state.
...(this.config.secondaryStorage ? { secondaryStorage: this.config.secondaryStorage } : {}),
// better-auth plugins — registered based on AuthPluginConfig flags
plugins,
// Database hooks (fired by better-auth's adapter writes — these run
// for SSO JIT-provisioning too, unlike kernel-level ObjectQL
// middleware which better-auth's adapter bypasses). The framework's
// identity-source stamp (`account.create.after`) is always composed in,
// preserving any host-supplied hooks.
databaseHooks: this.composeDatabaseHooks(this.config.databaseHooks),
// Bootstrap bypass for `disableSignUp`. The first-run owner wizard
// (`/_account/setup`) calls `POST /auth/sign-up/email` to create
// the very first user — if `OS_DISABLE_SIGNUP=true` is set on a
// fresh install we'd lock the operator out of their own instance.
// Solution: when the request hits `/sign-up/email` AND no users
// exist yet, temporarily flip `disableSignUp` off for *this*
// request's context. Once the owner is created the next request
// sees `userCount > 0` and the toggle is enforced again.
hooks: {
before: createAuthMiddleware(async (ctx: any) => {
// ── #2780: per-number OTP send guard (admission control) ─────
// MUST run BEFORE the phone-number endpoints: better-auth's
// send-otp handler stores a fresh code and only THEN invokes
// `sendOTP` — a guard that throws inside the callback would
// still rotate (invalidate) the previously delivered code, so a
// blocked resend (or an attacker spamming the endpoint) could
// keep voiding the user's valid OTP. Rejecting here leaves the
// stored code untouched. Applies uniformly to registered and
// unregistered numbers (no account-existence oracle).
if (
ctx?.path === '/phone-number/send-otp' ||
ctx?.path === '/phone-number/request-password-reset'
) {
const phone = typeof ctx?.body?.phoneNumber === 'string' ? ctx.body.phoneNumber : '';
await this.assertPhoneOtpSendAllowed(phone);
}
// ── ADR-0069 D1: password complexity (validator) ────────────
// better-auth enforces only min/max length; class-mix is custom.
// Runs on the password-mutating endpoints; reads the candidate from
// the path-appropriate body field (sign-up: `password`; reset /
// change: `newPassword`).
if (
ctx?.path === '/sign-up/email' ||
ctx?.path === '/reset-password' ||
ctx?.path === '/change-password'
) {
const candidate =
(typeof ctx?.body?.password === 'string' && ctx.body.password) ||
(typeof ctx?.body?.newPassword === 'string' && ctx.body.newPassword) ||
'';
if (candidate) await this.assertPasswordComplexity(candidate);
// ── ADR-0069 D1: password history (reject reuse) ────────────
// change/reset only (sign-up has no prior history). Reuses
// better-auth's native password.verify — no bespoke crypto. Stashes
// the old hash so the after-hook appends it to the bounded ring on
// success.
if (
candidate &&
(ctx?.path === '/reset-password' || ctx?.path === '/change-password')
) {
const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => undefined);
if (userId) {
// Stash for the after-hook (password_changed_at stamp), regardless
// of whether history is enabled.
ctx.context.__osPwChangeUserId = userId;
const pw = ctx?.context?.password;
const verify = typeof pw?.verify === 'function' ? pw.verify.bind(pw) : undefined;
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
if (oldHash !== undefined) ctx.context.__osPwHistory = { userId, oldHash };
}
}
// fall through to the path's own handling below
}
// ── ADR-0024: admin-gate self-service SSO provider registration ──
// `@better-auth/sso`'s POST /sso/register only checks org-admin when
// `body.organizationId` is present (index.mjs: `if (ctx.body
// .organizationId) { … hasOrgAdminRole … }`). A GLOBAL (org-less)
// provider therefore passes with nothing but a valid session — so any
// authenticated member can register an env-wide external IdP, a JIT-
// provisioning / login-routing vector. Require the caller to be a
// platform admin OR an owner/admin of their active org, regardless of
// whether `organizationId` is supplied. Unauthenticated requests fall
// through to better-auth's `sessionMiddleware` (→ 401). Fail-CLOSED:
// an unverifiable actor is denied. (D5.1's `/oauth2/authorize` gate is
// a different surface — the OP issuing codes, not the env's RP config.)
if (ctx?.path === '/sso/register') {
const actor = await this.resolveActor(ctx);
if (actor?.userId) {
const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId);
if (!ok) {
const { APIError } = await import('better-auth/api');
throw new APIError('FORBIDDEN', {
message:
'Only an organization owner/admin or a platform admin can ' +
'register an SSO provider.',
code: 'SSO_REGISTER_FORBIDDEN',
});
}
}
return;
}
// ── D5.1: cloud-as-IdP authorization gate ───────────────────
// On the OIDC OP's /oauth2/authorize, when a host gate is set
// (cloud control plane), an AUTHENTICATED subject must be
// authorized for the requesting client (env) before a code is
// issued — this enforces org-membership (app-assignment). Unset
// (open editions / self-host) → no gate. Unauthenticated → fall
// through so the OP redirects to login; the gate runs on the
// return pass (or immediately for a bearer/cookie session).
if (ctx?.path === '/oauth2/authorize' && this.config.oidcAuthorizeGate) {
const clientId = ctx?.query?.client_id;
if (clientId) {
let gateUserId: string | undefined;
// (a) standard resolver — handles the cookie session.
try {
const { getSessionFromCtx } = await import('better-auth/api');
const s: any = await getSessionFromCtx(ctx as any);
gateUserId = s?.user?.id ?? s?.session?.userId;
} catch { /* fall through to explicit resolution */ }
// (b) explicit token resolution — hook-order-independent. The
// bearer plugin may convert `Authorization: Bearer` to a session
// AFTER this global before-hook, so getSessionFromCtx can miss a
// bearer (or non-default cookie) request here. Resolve the token
// (bearer or the session cookie's token part) and look it up.
if (!gateUserId) {
try {
const hdr = (k: string): string =>
((ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) as string) || '';
let token: string | undefined;
const bm = /^Bearer\s+(.+)$/i.exec(hdr('authorization'));
if (bm?.[1]) token = bm[1].trim();
if (!token) {
const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr('cookie'));
if (cm?.[1]) token = decodeURIComponent(cm[1]).split('.')[0];
}
if (token) {
const sess: any = await (ctx as any).context.adapter.findOne({
model: 'session',
where: [{ field: 'token', value: token }],
});
const exp = sess?.expiresAt ?? sess?.expires_at;
if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
gateUserId = String(sess.userId ?? sess.user_id ?? '') || undefined;
}
}
} catch { /* unresolved → fall through, OP handles auth */ }
}
if (gateUserId) {
const allowed = await this.config.oidcAuthorizeGate({
userId: gateUserId,
clientId: String(clientId),
});
if (!allowed) {
const { APIError } = await import('better-auth/api');
throw new APIError('FORBIDDEN', {
message: 'You are not authorized to sign in to this environment.',
code: 'ENV_ACCESS_DENIED',
});
}