Repository navigation
Expand file tree
/
Copy pathauth-manager.ts
More file actions
9708 lines (9312 loc) · 495 KB
/
Copy pathauth-manager.ts
File metadata and controls
9708 lines (9312 loc) · 495 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';
import type { JSONWebKeySet } from 'jose';
import type { OrganizationOptions } from 'better-auth/plugins/organization';
import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim';
// 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 {
SystemObjectName,
audiencePermitsSelfRegistration,
preferredLocaleFromHeader,
} from '@objectstack/spec/system';
import {
assertAudienceConfig,
classifyCreationMethod,
decideAudienceAdmission,
isHumanUserRow,
resolveAudience,
resolveEmailVerificationRequirement,
AUDIENCE_CONFIG_ERROR,
type AudienceCreationClass,
type ResolvedAudience,
type VerificationDeclarant,
} from './audience-posture.js';
import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js';
import {
PLATFORM_IDP_PROVIDER_ID,
clearUnlinkTombstone,
clearUserUnlinkTombstones,
recordUnlinkTombstone,
type LinkingAuthContext,
refuseImplicitAccountLink,
} from './implicit-account-linking.js';
import type { IDataEngine } from '@objectstack/core';
// [#10348] The ONE id-shaped platform-admin predicate (ADR-0068 D2).
// `auth-manager` used to re-derive that standing itself, in two spellings
// that had drifted from the declared authority and from each other; both
// now ask the authority. Nothing in this file reads the grant tables.
import {
API_KEY_DEFAULT_EXPIRY_DAYS,
API_KEY_INACTIVITY_DAYS,
API_KEY_MAX_EXPIRY_DAYS,
hasPlatformAdminStanding,
resolveUserAuthzGrants,
} from '@objectstack/core';
import type { AuthGate } from '@objectstack/core';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type {
ApiKeyLifecyclePolicy,
IEmailService,
II18nService,
ISmsService,
PrincipalSession,
PrincipalSessionMintOptions,
} from '@objectstack/spec/contracts';
import {
readEnvWithDeprecation,
resolveTenancyPosture,
resolveOrgLimit,
isMcpServerEnabled,
} from '@objectstack/types';
import { resolveMembershipLimitOption } from './membership-limit.js';
import {
MEMBERSHIP_ROLE_DELEGATED_ADMIN,
} from '@objectstack/spec';
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api';
import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js';
import { permissionSetInEffect } from './catalog-set-in-effect.js';
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js';
import {
emitAuthSessionAuditEvent,
loginEventFor,
logoutEventFor,
type AuthEventAuditSurface,
} from './auth-session-audit.js';
import { SESSION_ERASURE_PATHS } from './session-tombstone.js';
import { envelopeVendorAdminRefusal } from './vendor-admin-refusal-envelope.js';
import { refuseAnonymousSession } from './anonymous-session-refusal.js';
import {
buildBetterAuthRouteOwnership,
type BetterAuthRouteOwnership,
} from './better-auth-route-ownership.js';
import {
ADMIN_SESSION_COOKIE_KEY,
STOP_IMPERSONATING_PATH,
rotateCallerBearerOnImpersonation,
withBearerAdminSessionRecovery,
} from './impersonation-bearer-rotation.js';
import { echoInstalledSessionToken } from './two-factor-rotated-token-echo.js';
import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-verified-reset.js';
import { attachSessionToCredentialResponse } from './session-envelope-completion.js';
import {
applyPlatformAdminImpersonation,
VENDOR_ROLE_SCALAR_ADMIN_PATHS,
} from './admin-impersonate-endpoint.js';
import { judgeHeldSession } from './platform-admin-gate.js';
import {
carriesPrincipalSession,
holdPrincipalSessionExpiry,
mintPrincipalSession,
type PrincipalSessionAuthContext,
} from './principal-session-mint.js';
import {
applyDeclaredInvitationVerificationToListing,
} from './list-user-invitations-verification.js';
import {
invitationRoleCapFailure,
isPlainMemberInvitation,
} from './invitation-role-cap.js';
import {
DEFAULT_CREATOR_ROLE,
REMOVE_MEMBER_DENIAL_CODE,
REMOVE_MEMBER_DENIAL_MESSAGE,
isSoleOwnerGuardTerritory,
removalBlockedByOwnerTarget,
} from './remove-member-permission-guard.js';
import { isPlaceholderEmail } from './placeholder-email.js';
import { readAccountInvitation, settleImportResetDelivery } from './account-invitation-marker.js';
import { resolveClientAddress } from './client-address.js';
import { clientAddressStateEntries, readRequestClientAddress } from './request-client-address.js';
import {
REVOKE_SESSION_NOT_FOUND_CODE,
REVOKE_SESSION_NOT_FOUND_MESSAGE,
revokeTargetsCallerSession,
} from './revoke-session-match-guard.js';
import {
ADMIN_REVOKE_USER_SESSION_NOT_FOUND_CODE,
ADMIN_REVOKE_USER_SESSION_NOT_FOUND_MESSAGE,
adminMayRevokeUserSessions,
anySessionCarriesToken,
} from './admin-revoke-user-session-match-guard.js';
import {
reconcileMembership,
type MembershipPolicy,
type ReconcileOutcome,
} from './reconcile-membership.js';
import type { TenancyService } from './tenancy-service.js';
import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js';
import type { CounterStore } from './rate-limit-storage.js';
import { GRANT_SET_NAME_FIELD } from './grant-set-name.js';
import {
isLastLocalCredentialHolder,
LAST_LOCAL_CREDENTIAL_CODE,
LAST_LOCAL_CREDENTIAL_MESSAGE,
} from './last-local-credential.js';
import {
applyUserBan,
applyUserUnban,
SCIM_DEACTIVATION_BAN_REASON,
} from './user-ban-write.js';
import {
PHONE_SMS_TOPICS,
interpolatePhoneSms,
resolvePhoneSmsTemplateBody,
} from './phone-sms-texts.js';
import { invitationRoleLabel } from './invitation-role-label.js';
// commit 35e94c96b — the stored rung of the ruled locale ladder reuses the messaging
// seam's normalizer rather than growing a second one. `normalizeRecipientLocale`
// is the platform's ONE reader of a value at rest in `sys_user.locale`, and its
// refusal of the stringified-nothing literals (`"undefined"`, `"null"`) is part
// of its contract — hotcrm measured that literal dead-lettering every delivery
// for every user without a preference row. A private copy here would be a
// second place for that refusal to rot.
import {
RECIPIENT_LOCALE_FIELD,
normalizeRecipientLocale,
} from '@objectstack/service-messaging';
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';
import {
createHostUsableJwksReader,
probeEd25519Support,
protectGetSessionJwtHook,
resolveJwtSigningAlgorithm,
} from './jwt-key-algorithm.js';
import {
createSigningKeyReader,
retireHeldKeyrings,
UNKNOWN_SIGNING_KEY_REREAD_FLOOR_MS,
} from './signing-key-read-cache.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();
console.warn(
'[AuthManager] WebContainer detected: installed synchronous request-state polyfill ' +
'(node:async_hooks AsyncLocalStorage does not propagate context across await in WebContainer).',
);
}
}
/**
* [#7724] Carries better-auth's own error `Response` out through the engine
* transaction that must roll back because of it.
*
* better-auth's HTTP entrypoint CATCHES every fault and RETURNS a `Response` —
* it does not throw. A `try`/`catch`-shaped unit of work therefore sees a clean
* return on the exact path it exists to undo, commits, and the partial writes
* land anyway. So the failure signal has to be re-raised from the response
* status, and the response itself has to survive the throw that rolls the
* transaction back — that is the whole job of this class. It never escapes
* `runSubjectErasureAtomically`, which unwraps it back into the response the
* client was always going to get.
*/
class SubjectErasureRollback extends Error {
constructor(readonly response: Response) {
super(`subject-erasure unit of work rolled back (HTTP ${response.status})`);
this.name = 'SubjectErasureRollback';
}
}
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');
}
/**
* [#16384] The auth plugin's shipped `basePath` default — the ONE place this
* literal is written. Before this card it existed independently at four
* sites: this file's own `configuredBasePath()` fallback below, plus three in
* `auth-plugin.ts` (the constructor default and two later re-derivations of
* "what if the caller cleared `basePath`?"). A future edit to one could drift
* from the other three silently — `configuredBasePath()`'s own fallback is
* unfalsifiable by construction on the live path (`AuthPlugin` always supplies
* `basePath`, per its constructor default below), so no runtime test could
* have caught that drift. Every one of the four readers now evaluates BYTE
* IDENTICALLY to before this constant existed — see `configuredBasePath()`'s
* docblock for the normalisation chain this does NOT touch.
*/
export const DEFAULT_AUTH_BASE_PATH = '/api/v1/auth';
/**
* 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()
);
}
/**
* SINGLE decision point for "is the SCIM provisioning surface effective?" —
* shared by `buildPluginList()`, the `/auth/config` features block and
* {@link assertScimAdminCoherence}, so the wired plugin, the advertised
* feature flag and the conflict refusal can never disagree.
*
* Resolution order (#13439, maintainer ruling 2026-08-31): an EXPLICIT
* `plugins.scim` wins over `OS_SCIM_ENABLED`; the env var decides only where
* the config leaves the key unset (absent everywhere ⇒ off).
*/
export function resolveScimEnabled(pluginConfig?: Partial<AuthPluginConfig>): boolean {
return pluginConfig?.scim ?? readBooleanEnv('OS_SCIM_ENABLED') ?? false;
}
/**
* Entry validation (#13816, maintainer ruling 2026-09-01) — REFUSES the one
* incoherent corner of the `admin` tri-state at the point the config enters
* the manager (constructor, `applyConfigPatch`, and `buildPluginList()` for
* env vars read after boot), loudly, with the remedy in the message.
*
* SCIM's `active:false` deprovisioning path runs through the better-auth
* `admin` plugin (ban/unban — ADR-0134), so a SCIM-enabled deployment cannot
* have the admin plugin declined: effective SCIM with an explicit
* `plugins.admin: false` is a contradiction. Previously the resolution
* honoured the explicit `false` and mounted SCIM anyway — deprovisioning
* broke silently. Absent `admin` is untouched: effective SCIM still forces
* the admin plugin on (`admin: pluginConfig.admin ?? scimEffective`).
* Whether SCIM's admin dependency can instead be narrowed to the actions it
* actually needs is #14150's measurement (Shape 1); this refusal is the
* honest floor pending that reading.
*/
export function assertScimAdminCoherence(pluginConfig?: Partial<AuthPluginConfig>): void {
if (pluginConfig?.admin === false && resolveScimEnabled(pluginConfig)) {
throw new Error(
'[auth] conflicting auth plugin configuration: SCIM provisioning is effective ' +
'(plugins.scim, or OS_SCIM_ENABLED where the config leaves it unset) while plugins.admin ' +
"is explicitly false. SCIM's active:false deprovisioning path runs through the better-auth " +
'admin plugin (ban/unban — ADR-0134), so a SCIM-enabled deployment cannot decline the admin ' +
'surface. Either accept the admin plugin (remove plugins.admin: false, or set it true), or ' +
'disable SCIM (plugins.scim: false — which also declines the admin plugin it would force on). ' +
'Refused loudly at construction instead of silently mounting SCIM without its deprovisioning ' +
'path.',
);
}
}
/**
* Expand an IPv6 literal (already unbracketed) to its eight 16-bit groups, or
* `null` when it is not one. Handles `::` elision and a trailing embedded
* IPv4 (`::ffff:10.0.0.5`) so the classifier below reads real bits rather
* than matching text prefixes.
*/
function parseIpv6Literal(host: string): number[] | null {
if (!/^[0-9a-f:.]+$/.test(host)) return null;
const halves = host.split('::');
if (halves.length > 2) return null;
const toGroups = (part: string): number[] | null => {
if (part === '') return [];
const pieces = part.split(':');
const out: number[] = [];
for (let i = 0; i < pieces.length; i++) {
const piece = pieces[i]!;
if (piece.includes('.')) {
// An embedded IPv4 tail is legal only in the last position.
if (i !== pieces.length - 1) return null;
const v4 = ipv4ToInt(piece);
if (v4 === null) return null;
out.push((v4 >>> 16) & 0xffff, v4 & 0xffff);
continue;
}
if (!/^[0-9a-f]{1,4}$/.test(piece)) return null;
out.push(parseInt(piece, 16));
}
return out;
};
const head = toGroups(halves[0]!);
if (head === null) return null;
if (halves.length === 1) return head.length === 8 ? head : null;
const tail = toGroups(halves[1]!);
if (tail === null) return null;
const elided = 8 - head.length - tail.length;
if (elided < 1) return null;
return [...head, ...new Array<number>(elided).fill(0), ...tail];
}
/**
* Is this host LITERAL a loopback, RFC 1918 private, RFC 4193 unique-local or
* link-local address? The ruled allow-list, and nothing wider:
*
* IPv4 127.0.0.0/8 (loopback) · 10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16
* 169.254.0.0/16 (link-local)
* IPv6 ::1 (loopback) · fc00::/7 (unique-local) · fe80::/10 (link-local)
*
* Boundaries that decide the verdict, each pinned in
* `auth-manager.mcp-oauth.test.ts`: `172.15.x` and `172.32.x` sit OUTSIDE
* RFC 1918 and are refused; `fec0::/10` site-local was deprecated by RFC 3879
* and is NOT inside `fc00::/7`, so it is refused too; an IPv4-mapped IPv6
* form (`::ffff:10.0.0.5`, which WHATWG URL canonicalises to `::ffff:a00:5`)
* is refused rather than unwrapped — the conservative side of a rule whose
* failure mode is opening plaintext OAuth to the public internet. The whole
* of `127.0.0.0/8` is loopback (RFC 1122 §3.2.1.3), not the single literal
* `127.0.0.1`, and `http://127.0.0.2` pins that.
*
* The IPv4 half is spelled with this file's own ADR-0069 D5 helpers
* ({@link ipMatchesRange}, `ipv4ToInt`) rather than a second dotted-quad
* regex: one parser for IPv4 literals in this file, and the ranges read as
* the CIDR blocks the ruling names.
*/
const PLAIN_HTTP_ELIGIBLE_IPV4_CIDRS = [
'127.0.0.0/8', // loopback (RFC 1122)
'10.0.0.0/8', // RFC 1918
'172.16.0.0/12', // RFC 1918
'192.168.0.0/16', // RFC 1918
'169.254.0.0/16', // RFC 3927 link-local
] as const;
function isPrivateOrLoopbackHostLiteral(host: string): boolean {
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
if (ipv4ToInt(bare) !== null) {
return PLAIN_HTTP_ELIGIBLE_IPV4_CIDRS.some((cidr) => ipMatchesRange(bare, cidr));
}
const v6 = parseIpv6Literal(bare);
if (v6) {
if (v6.every((g, i) => (i === 7 ? g === 1 : g === 0))) return true; // ::1
const first = v6[0]!;
if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7
if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10
return false;
}
return false;
}
/**
* OAuth 2.1 §1.5 transport rule for the MCP OAuth track, applied at
* DEPLOYMENT level with the semantics of Keycloak's `sslRequired=external`:
* TLS is required on a PUBLIC host, and plain HTTP is accepted when the
* deployment's own canonical origin is a loopback or private / link-local
* address. A plain-HTTP PUBLIC 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.
*
* ## Why private hosts are eligible (maintainer ruling 2026-09-21)
*
* A deployment that already serves its login form and session cookies over
* plain HTTP gains nothing from OAuth refusing plain HTTP: the refusal only
* removes MCP from that deployment. Intranet installs and a developer's
* `os dev` bound to a LAN address are the same case, so there is no separate
* development-mode branch and ⛔ no configuration key or environment variable
* — a switch would be reachable on a public host, which is exactly the
* deployment this rule must keep refusing.
*
* ## The reading pinned for a non-IP hostname
*
* This judges the HOST LITERAL of the deployment's own canonical origin. An
* intranet hostname (`crm.corp`, `host.docker.internal`) is not an IP
* literal, so over plain HTTP it stays REFUSED, and the remedy is to
* configure the base URL on the private address the deployment already binds
* (`http://192.168.1.10:3000`). The two alternatives were both rejected, on
* the record:
*
* - **Resolving the hostname in DNS** would make a pure, synchronous
* predicate depend on a network round trip on every `isMcpOAuthEnabled()`
* call, and a name whose answer can change (or be rebound) turns the
* transport verdict into something no test can pin.
* - **Judging the REQUESTER's peer address**, which is what Keycloak's
* `sslRequired=external` does, cannot decide what this deployment
* ADVERTISES at mount time, and it carries a false-safe we would be
* adopting knowingly: a plain-HTTP reverse proxy on a public address
* forwards every request from a private peer, so every requester looks
* internal and plaintext OAuth is served to the public internet. The same
* false-safe reaches this rule from the other side — a private canonical
* origin that a public plain-HTTP proxy fronts is eligible here — and it is
* documented rather than keyed, exactly as Keycloak documents its own.
*/
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();
if (host === 'localhost' || host.endsWith('.localhost')) return true;
return isPrivateOrLoopbackHostLiteral(host);
} catch {
return false;
}
}
/**
* Extended options for AuthManager
*/
/**
* [ADR-0105 D8] The slice of `@objectstack/plugin-security`'s
* `invitation-placement` service plugin-auth depends on. Structural, so
* plugin-auth gains no dependency on plugin-security.
*/
export interface InvitationPlacementServiceLike {
/**
* Refuses with the gate's `PermissionDeniedError` (`code: 'PERMISSION_DENIED'`)
* when the issuer may not place this invitation. Any other throw is a failure
* to reach that verdict, such as a store that could not answer.
*/
assertIssuable(args: {
intent: { businessUnitId: string; positions: string[] };
/** The ISSUER's user id — the service resolves their grants itself. */
actorUserId?: string | null;
organizationId?: string | null;
}): Promise<void>;
apply(args: {
intent: { businessUnitId: string; positions: string[] };
userId: string;
organizationId?: string | null;
grantedBy?: string | null;
}): Promise<{ created: number; skipped: number }>;
}
/**
* [ADR-0105 D8] Normalize placement intent off an invitation draft/row.
* Accepts better-auth's camelCase draft shape and the snake_case persisted
* row, and a `positions` value that survived a JSON round-trip as a string.
* Returns `null` when there is no usable intent — an invitation WITHOUT
* placement is the ordinary case, not an error.
*/
export function readInvitationPlacementIntent(
row: unknown,
): { businessUnitId: string; positions: string[] } | null {
const r = (row ?? {}) as Record<string, unknown>;
const buRaw = r.businessUnitId ?? r.business_unit_id;
const businessUnitId = typeof buRaw === 'string' && buRaw !== '' ? buRaw : null;
let posRaw: unknown = r.positions;
if (typeof posRaw === 'string') {
try {
posRaw = JSON.parse(posRaw);
} catch {
posRaw = [posRaw];
}
}
const positions = Array.isArray(posRaw)
? posRaw.filter((p): p is string => typeof p === 'string' && p !== '')
: [];
if (!businessUnitId || positions.length === 0) return null;
return { businessUnitId, positions };
}
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>;
/**
* [ADR-0105 D8] Optional callback invoked AFTER an invitation is accepted
* via better-auth (`organizationHooks.afterAcceptInvitation`) — the seam a
* host uses to apply an invitation's PLACEMENT INTENT (business-unit
* membership + position assignments, carried as extension fields on
* `sys_invitation` per the ADR-0092 whitelist) the moment the better-auth
* membership lands. Same rationale as {@link onOrganizationCreated}: the
* org-plugin models don't fire core `databaseHooks`, so this is the only
* server-side seam for accept-time side effects.
*
* `invitation` / `member` are the full better-auth rows, so a host reads
* its own extension columns without a second query. Failure-isolated: the
* acceptance is never rolled back on a side-effect miss — a host that
* needs placement to be effectively atomic should make the callback
* idempotent and reconcile on retry.
*/
/**
* [ADR-0105 D8] Lazy accessor for the `invitation-placement` service
* (registered by `@objectstack/plugin-security`). Wired by `AuthPlugin`;
* lazy because plugin-security starts after plugin-auth.
*
* It carries the authority for scoped invitations: issuance authorizes the
* requested placement against the ISSUER's `adminScope`, acceptance applies
* it. Absent (an embedding without plugin-security) ⇒ an invitation that
* requests placement is REFUSED — there is no gate to check it against, and
* an unchecked placement would hand `organization_admin` the RBAC-write
* authority it is deliberately denied elsewhere.
*/
invitationPlacement?: () => Promise<InvitationPlacementServiceLike | undefined>;
onInvitationAccepted?: (data: {
invitationId?: string;
organizationId?: string;
userId?: string;
memberId?: string;
role?: string;
email?: string;
invitation?: Record<string, unknown>;
member?: Record<string, unknown>;
}) => 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;
/**
* `additionalOrgRoles` was REMOVED in ADR-0108 (#3723). The organization-role
* vocabulary is closed: `owner` / `admin` / `delegated_admin` / `member`.
*
* It registered app-declared `position` / `permission` names with
* better-auth so invitations could name them. Because every value stored in
* `sys_member.role` is projected into `current_user.positions`, that made the
* membership role a capability channel with none of ADR-0090 D12's controls.
*
* FROM → TO:
* `new AuthPlugin({ additionalOrgRoles: ['sales_rep'] })`
* → declare `sales_rep` as a `position`, then either assign it
* (`sys_user_position`, governed by `DelegatedAdminGate`) or invite with
* placement: `POST /organization/invite-member`
* `{ role: 'member', businessUnitId, positions: ['sales_rep'] }`
* (ADR-0105 D8 — authorized against the issuer's `adminScope`).
*/
/**
* 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
* 400 `SMS_SERVICE_REQUIRED` 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.
* Enforced at the declared length up to 24 hours (`MAX_COOLDOWN_SECONDS`);
* a longer value is REJECTED at construction, never truncated (#4808).
*/
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;
};
/**
* The deployment's product name: the `{{appName}}` placeholder of the
* built-in auth email templates, and the issuer and label prefix of the
* otpauth URI a TOTP enrollment hands to the user's authenticator app.
* Defaults to `'ObjectStack'` when omitted.
*/
appName?: string;
/**
* ADR-0093 D9 — 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, including paths that never reach an
* HTTP route of ours (e.g. `genericOAuth` JIT-creating a user during SSO
* login).
*
* **Use this seam — not an ObjectQL middleware on `sys_user` — to react to
* user creation.** The reason is ADR-0093 D2: an invariant over the user
* lifecycle gets exactly ONE owner, composed into `user.create.after`
* (`reconcile-membership.ts`), because that is the single seam every
* creation path already flows through (self-signup, admin create-user,
* import, SSO JIT). Re-deriving the invariant per creation path — or in a
* parallel data-layer middleware — is precisely the shape D2 eliminated.
* The platform's own use is a `user.create.after` hook that provisions a
* personal/default organization so SSO-arriving users don't land on the
* empty "create organization" screen.
*
* **Corrected mechanism (#4802).** This comment used to justify the rule by
* asserting that better-auth's adapter "goes through `dataEngine` directly,
* bypassing the `ql.registerMiddleware` chain". That is **not** true, and
* the claim was copied widely enough (framework + cloud) to keep producing
* wrong architectural conclusions, so it is refuted here rather than quietly
* deleted. Verified hop by hop:
*
* - `objectql/src/plugin.ts` registers ONE instance under both names —
* `registerService('objectql', this.ql)` and `registerService('data',
* this.ql)`; nothing else in the repo registers `data`.
* - `auth-plugin.ts` takes `ctx.getService<IDataEngine>('data')` and hands
* that same instance to {@link createObjectQLAdapterFactory}.
* - `objectql-adapter.ts` writes with plain `dataEngine.insert(objectName,
* …)` — there is no bypass/skip-middleware option to pass.
* - `ObjectQL.insert()` (`objectql/src/engine.ts`) wraps its body in
* `executeWithMiddleware()`, whose only filter is the object name.
*
* So `ql.registerMiddleware(fn, { object: 'sys_user' })` **does** fire for
* better-auth's writes, and so do the engine's `beforeInsert`/`afterInsert`
* lifecycle hooks (the SCIM identity-source stamp in `auth-plugin.ts` relies
* on exactly that). What remains true is narrower and worth knowing: adapter
* writes carry `context.isSystem: true` (`withSystemContext`, pinned by
* `objectql-adapter.test.ts`), and every authorization middleware —
* security, sharing, the ADR-0092 identity write guard — early-returns on
* `isSystem` by design. A middleware that gates on `isSystem` therefore sees
* nothing; one that does not, runs.
*/
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;
/**
* [#8144] Accessor for the `audit` service — the compliance ledger's ingress
* for events that are not CRUD. Consulted by the session lifecycle hooks to
* record `login` / `logout` in `sys_audit_log`.
*
* Lazy for the same reason `getTenancy` is: the service is registered on the
* kernel after the AuthManager is constructed, and every call happens at
* request time. Omitted, or resolving to `undefined` (the audit plugin is not
* installed — it is an OPTIONAL pair in the CLI) → no auth rows are written,
* which is exactly today's behaviour.
*/
getAuditSink?: () => AuthEventAuditSurface | undefined;
/**
* Accessor for the runtime's `i18n` service, read by the organization
* invitation mail to name the invitee's membership role by its option LABEL
* in the mail's locale (`成员`, not `member`) — the label the console shows
* for the same option, read through the same service, so a tenant's authored
* translation reaches the mail too.
*
* Lazy for the same reason `getTenancy` is: the service is registered on the
* kernel after the AuthManager is constructed, and every call happens at
* send time. Omitted, or resolving to `undefined` (no i18n service) → the mail
* names the option's authored label (`Member`), never the stored value.
*/
getI18n?: () => II18nService | 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).
*
* The whole field stays OPTIONAL — omitting it is still a supported posture.
* What is no longer representable is supplying a logger that cannot carry a
* durability report: `warn` is non-optional because this value is FORWARDED
* verbatim into `ReconcileMembershipDeps.logger` (reconcile-membership.ts),
* whose sink guarantees that channel under #9754. Measured before tightening:
* the only non-test construction site in this repo is `auth-plugin.ts`, which
* passes `ctx.logger` — the kernel `Logger`, whose `warn` is already
* required — so the in-tree cost is zero. An external embedder handing
* `AuthManager` a reduced `{ info }` sink is the one caller this asks to
* change, and that is the point: it was the caller silently discarding the
* reconciler's reports (#10556).
*/
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;
/**