@objectstack/plugin-auth 10.3.0 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  import { SysOrganizationDetailPage, SysUserDetailPage } from "@objectstack/platform-objects/pages";
8
8
 
9
9
  // src/auth-manager.ts
10
- import { readEnvWithDeprecation } from "@objectstack/types";
10
+ import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from "@objectstack/types";
11
+ import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from "@objectstack/spec";
11
12
 
12
13
  // src/objectql-adapter.ts
13
14
  import { createAdapterFactory } from "better-auth/adapters";
@@ -16,7 +17,16 @@ var AUTH_MODEL_TO_PROTOCOL = {
16
17
  user: SystemObjectName.USER,
17
18
  session: SystemObjectName.SESSION,
18
19
  account: SystemObjectName.ACCOUNT,
19
- verification: SystemObjectName.VERIFICATION
20
+ verification: SystemObjectName.VERIFICATION,
21
+ // Plugin models. `@better-auth/sso` and `@better-auth/scim` both hardcode
22
+ // their model name and accept NO `schema` option (verified vs 1.6.2x — no
23
+ // mergeSchema, runtime never reads options.schema), so the table name is
24
+ // bridged here and `createObjectQLAdapterFactory` (below) auto-maps their
25
+ // camelCase fields to snake_case (oidcConfig→oidc_config, scimToken→
26
+ // scim_token, …) on every CRUD op via resolveProtocolName. Off by default
27
+ // (OS_SSO_ENABLED / OS_SCIM_ENABLED). See ADR-0024 / ADR-0071.
28
+ ssoProvider: "sys_sso_provider",
29
+ scimProvider: "sys_scim_provider"
20
30
  };
21
31
  function resolveProtocolName(model) {
22
32
  return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
@@ -80,7 +90,28 @@ function convertWhere(where) {
80
90
  }
81
91
  return filter;
82
92
  }
83
- function createObjectQLAdapterFactory(dataEngine) {
93
+ function withSystemReadContext(engine) {
94
+ const e = engine;
95
+ const asSystem = (q) => ({ ...q ?? {}, context: { isSystem: true, ...q?.context ?? {} } });
96
+ return {
97
+ insert: (m, d) => e.insert(m, d),
98
+ update: (m, d) => e.update(m, d),
99
+ delete: (m, q) => e.delete(m, q),
100
+ find: (m, q) => e.find(m, asSystem(q)),
101
+ findOne: (m, q) => e.findOne(m, asSystem(q)),
102
+ count: (m, q) => e.count(m, asSystem(q))
103
+ };
104
+ }
105
+ function createObjectQLAdapterFactory(rawDataEngine) {
106
+ const dataEngine = withSystemReadContext(rawDataEngine);
107
+ const camelToSnake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
108
+ const snakeToCamel = (s) => s.replace(/_([a-z])/g, (_m, c) => c.toUpperCase());
109
+ const remapKeys = (obj, fn) => {
110
+ const out = {};
111
+ for (const k of Object.keys(obj)) out[fn(k)] = obj[k];
112
+ return out;
113
+ };
114
+ const remapWhere = (where) => where.map((c) => ({ ...c, field: camelToSnake(c.field) }));
84
115
  return createAdapterFactory({
85
116
  config: {
86
117
  adapterId: "objectql",
@@ -95,62 +126,90 @@ function createObjectQLAdapterFactory(dataEngine) {
95
126
  },
96
127
  adapter: () => ({
97
128
  create: async ({ model, data, select: _select }) => {
98
- const result = await dataEngine.insert(model, data);
99
- return normaliseLegacyDates(model, result);
129
+ const objectName = resolveProtocolName(model);
130
+ const bridged = objectName !== model;
131
+ const result = await dataEngine.insert(objectName, bridged ? remapKeys(data, camelToSnake) : data);
132
+ const norm = normaliseLegacyDates(model, result);
133
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
100
134
  },
101
135
  findOne: async ({ model, where, select, join: _join }) => {
102
- const filter = convertWhere(where);
103
- const result = await dataEngine.findOne(model, { where: filter, fields: select });
104
- return result ? normaliseLegacyDates(model, result) : null;
136
+ const objectName = resolveProtocolName(model);
137
+ const bridged = objectName !== model;
138
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
139
+ const fields = bridged && select ? select.map(camelToSnake) : select;
140
+ const result = await dataEngine.findOne(objectName, { where: filter, fields });
141
+ if (!result) return null;
142
+ const norm = normaliseLegacyDates(model, result);
143
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
105
144
  },
106
145
  findMany: async ({ model, where, limit, offset, sortBy, join: _join }) => {
107
- const filter = where ? convertWhere(where) : {};
108
- const orderBy = sortBy ? [{ field: sortBy.field, order: sortBy.direction }] : void 0;
109
- const results = await dataEngine.find(model, {
146
+ const objectName = resolveProtocolName(model);
147
+ const bridged = objectName !== model;
148
+ const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
149
+ const orderBy = sortBy ? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction }] : void 0;
150
+ const results = await dataEngine.find(objectName, {
110
151
  where: filter,
111
152
  limit: limit || 100,
112
153
  offset,
113
154
  orderBy
114
155
  });
115
- return results.map((r) => normaliseLegacyDates(model, r));
156
+ return results.map((r) => {
157
+ const norm = normaliseLegacyDates(model, r);
158
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
159
+ });
116
160
  },
117
161
  count: async ({ model, where }) => {
118
- const filter = where ? convertWhere(where) : {};
119
- return await dataEngine.count(model, { where: filter });
162
+ const objectName = resolveProtocolName(model);
163
+ const bridged = objectName !== model;
164
+ const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
165
+ return await dataEngine.count(objectName, { where: filter });
120
166
  },
121
167
  update: async ({ model, where, update }) => {
122
- const filter = convertWhere(where);
123
- const record = await dataEngine.findOne(model, { where: filter });
168
+ const objectName = resolveProtocolName(model);
169
+ const bridged = objectName !== model;
170
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
171
+ const record = await dataEngine.findOne(objectName, { where: filter });
124
172
  if (!record) return null;
125
- const result = await dataEngine.update(model, { ...update, id: record.id });
126
- return result ? normaliseLegacyDates(model, result) : null;
173
+ const patch = bridged ? remapKeys(update, camelToSnake) : update;
174
+ const result = await dataEngine.update(objectName, { ...patch, id: record.id });
175
+ if (!result) return null;
176
+ const norm = normaliseLegacyDates(model, result);
177
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
127
178
  },
128
179
  updateMany: async ({ model, where, update }) => {
129
- const filter = convertWhere(where);
130
- const records = await dataEngine.find(model, { where: filter });
180
+ const objectName = resolveProtocolName(model);
181
+ const bridged = objectName !== model;
182
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
183
+ const records = await dataEngine.find(objectName, { where: filter });
184
+ const patch = bridged ? remapKeys(update, camelToSnake) : update;
131
185
  for (const record of records) {
132
- await dataEngine.update(model, { ...update, id: record.id });
186
+ await dataEngine.update(objectName, { ...patch, id: record.id });
133
187
  }
134
188
  return records.length;
135
189
  },
136
190
  delete: async ({ model, where }) => {
137
- const filter = convertWhere(where);
138
- const record = await dataEngine.findOne(model, { where: filter });
191
+ const objectName = resolveProtocolName(model);
192
+ const bridged = objectName !== model;
193
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
194
+ const record = await dataEngine.findOne(objectName, { where: filter });
139
195
  if (!record) return;
140
- await dataEngine.delete(model, { where: { id: record.id } });
196
+ await dataEngine.delete(objectName, { where: { id: record.id } });
141
197
  },
142
198
  deleteMany: async ({ model, where }) => {
143
- const filter = convertWhere(where);
144
- const records = await dataEngine.find(model, { where: filter });
199
+ const objectName = resolveProtocolName(model);
200
+ const bridged = objectName !== model;
201
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
202
+ const records = await dataEngine.find(objectName, { where: filter });
145
203
  for (const record of records) {
146
- await dataEngine.delete(model, { where: { id: record.id } });
204
+ await dataEngine.delete(objectName, { where: { id: record.id } });
147
205
  }
148
206
  return records.length;
149
207
  }
150
208
  })
151
209
  });
152
210
  }
153
- function createObjectQLAdapter(dataEngine) {
211
+ function createObjectQLAdapter(rawDataEngine) {
212
+ const dataEngine = withSystemReadContext(rawDataEngine);
154
213
  return {
155
214
  create: async ({ model, data, select: _select }) => {
156
215
  const objectName = resolveProtocolName(model);
@@ -456,6 +515,31 @@ function buildOauthProviderPluginSchema() {
456
515
  };
457
516
  }
458
517
  var buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
518
+ var AUTH_SSO_PROVIDER_SCHEMA = {
519
+ modelName: "sys_sso_provider",
520
+ fields: {
521
+ providerId: "provider_id",
522
+ oidcConfig: "oidc_config",
523
+ samlConfig: "saml_config",
524
+ userId: "user_id",
525
+ organizationId: "organization_id",
526
+ // DNS domain-ownership proof (ADR-0024 ②). @better-auth/sso writes
527
+ // `domainVerified` on its `ssoProvider` model when domain verification is
528
+ // enabled; map it so the env can surface a verified/unverified badge. The
529
+ // one-time `domainVerificationToken` is NOT a provider column — it lives in
530
+ // the verification table and is returned only from request-domain-verification.
531
+ domainVerified: "domain_verified"
532
+ }
533
+ };
534
+ var AUTH_SCIM_PROVIDER_SCHEMA = {
535
+ modelName: "sys_scim_provider",
536
+ fields: {
537
+ providerId: "provider_id",
538
+ scimToken: "scim_token",
539
+ organizationId: "organization_id",
540
+ userId: "user_id"
541
+ }
542
+ };
459
543
  function buildDeviceAuthorizationPluginSchema() {
460
544
  return {
461
545
  deviceCode: AUTH_DEVICE_CODE_SCHEMA
@@ -520,9 +604,39 @@ function readDisableSignUpEnv() {
520
604
  if (signupEnabled != null) return !signupEnabled;
521
605
  return readBooleanEnv("OS_DISABLE_SIGNUP");
522
606
  }
607
+ function readSsoOnlyEnv() {
608
+ return readBooleanEnv("OS_AUTH_SSO_ONLY");
609
+ }
610
+ function ipv4ToInt(ip) {
611
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim());
612
+ if (!m) return null;
613
+ const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
614
+ if (p.some((n) => n > 255)) return null;
615
+ return (p[0] << 24 >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3] >>> 0;
616
+ }
617
+ function ipMatchesRange(ip, range) {
618
+ const r = (range || "").trim();
619
+ if (!r) return false;
620
+ if (r.includes("/")) {
621
+ const [base, bitsStr] = r.split("/");
622
+ const bits = Number(bitsStr);
623
+ const ipInt = ipv4ToInt(ip);
624
+ const baseInt = ipv4ToInt(base);
625
+ if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) {
626
+ return ip.trim() === base.trim();
627
+ }
628
+ const mask = bits === 0 ? 0 : ~0 << 32 - bits >>> 0;
629
+ return (ipInt & mask) >>> 0 === (baseInt & mask) >>> 0;
630
+ }
631
+ return ip.trim() === r;
632
+ }
523
633
  var AuthManager = class {
524
634
  constructor(config) {
525
635
  this.auth = null;
636
+ // ADR-0069 — cached "does any org require MFA" flag (per-org tightening).
637
+ // Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap.
638
+ this._orgMfaCache = { value: false, at: 0 };
639
+ this._orgMfaRefreshing = false;
526
640
  this.config = config;
527
641
  installWebContainerRequestStatePolyfill();
528
642
  if (config.authInstance) {
@@ -559,6 +673,14 @@ var AuthManager = class {
559
673
  // createAdapterFactory.
560
674
  user: {
561
675
  ...AUTH_USER_CONFIG
676
+ // NOTE: the env-side AI-seat marker `sys_user.ai_access` is deliberately
677
+ // NOT declared as a better-auth additionalField. sys_user is a
678
+ // better-auth-MANAGED table and better-auth SELECTs explicit columns, so
679
+ // declaring it here would make getSession query a column that may not
680
+ // exist on every env yet → broken auth. Instead the column is owned by
681
+ // the objectql `SysUser` object def (provisioned by boot schema-sync)
682
+ // and read by a GUARDED system query in resolveCtx (can only no-op,
683
+ // never break auth). better-auth stays oblivious to the extra column.
562
684
  },
563
685
  account: {
564
686
  ...AUTH_ACCOUNT_CONFIG,
@@ -607,7 +729,7 @@ var AuthManager = class {
607
729
  // lock the registration policy without relying on UI state.
608
730
  emailAndPassword: (() => {
609
731
  const disableSignUpFromEnv = readDisableSignUpEnv();
610
- const effectiveDisableSignUp = disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
732
+ const effectiveDisableSignUp = this.resolveSsoOnly() ? true : disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
611
733
  return {
612
734
  enabled: this.config.emailAndPassword?.enabled ?? true,
613
735
  ...passwordHasher ? { password: passwordHasher } : {},
@@ -621,28 +743,28 @@ var AuthManager = class {
621
743
  sendResetPassword: async ({ user, url, token }) => {
622
744
  const email = this.getEmailService();
623
745
  if (!email) {
624
- console.warn(
625
- `[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`
746
+ throw new Error(
747
+ `Password-reset email could not be sent to ${user.email}: no email service is configured for this deployment.`
626
748
  );
627
- return;
628
749
  }
629
750
  const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
630
- try {
631
- await email.sendTemplate({
632
- template: "auth.password_reset",
633
- to: { address: user.email, ...user.name ? { name: user.name } : {} },
634
- data: {
635
- user: { name: user.name || user.email, email: user.email, id: user.id },
636
- resetUrl: url,
637
- token,
638
- expiresInMinutes: Math.round(ttlSec / 60),
639
- appName: this.getAppName()
640
- },
641
- relatedObject: "sys_user",
642
- relatedId: user.id
643
- });
644
- } catch (err) {
645
- console.error(`[AuthManager] sendResetPassword failed (swallowed): ${err?.message ?? err}`);
751
+ const result = await email.sendTemplate({
752
+ template: "auth.password_reset",
753
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
754
+ data: {
755
+ user: { name: user.name || user.email, email: user.email, id: user.id },
756
+ resetUrl: url,
757
+ token,
758
+ expiresInMinutes: Math.round(ttlSec / 60),
759
+ appName: this.getAppName()
760
+ },
761
+ relatedObject: "sys_user",
762
+ relatedId: user.id
763
+ });
764
+ if (result?.status === "failed") {
765
+ throw new Error(
766
+ `Password-reset email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
767
+ );
646
768
  }
647
769
  }
648
770
  };
@@ -657,28 +779,28 @@ var AuthManager = class {
657
779
  sendVerificationEmail: async ({ user, url, token }) => {
658
780
  const email = this.getEmailService();
659
781
  if (!email) {
660
- console.warn(
661
- `[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`
782
+ throw new Error(
783
+ `Verification email could not be sent to ${user.email}: no email service is configured for this deployment.`
662
784
  );
663
- return;
664
785
  }
665
786
  const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
666
- try {
667
- await email.sendTemplate({
668
- template: "auth.verify_email",
669
- to: { address: user.email, ...user.name ? { name: user.name } : {} },
670
- data: {
671
- user: { name: user.name || user.email, email: user.email, id: user.id },
672
- verificationUrl: url,
673
- token,
674
- expiresInMinutes: Math.round(ttlSec / 60),
675
- appName: this.getAppName()
676
- },
677
- relatedObject: "sys_user",
678
- relatedId: user.id
679
- });
680
- } catch (err) {
681
- console.error(`[AuthManager] sendVerificationEmail failed (swallowed): ${err?.message ?? err}`);
787
+ const result = await email.sendTemplate({
788
+ template: "auth.verify_email",
789
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
790
+ data: {
791
+ user: { name: user.name || user.email, email: user.email, id: user.id },
792
+ verificationUrl: url,
793
+ token,
794
+ expiresInMinutes: Math.round(ttlSec / 60),
795
+ appName: this.getAppName()
796
+ },
797
+ relatedObject: "sys_user",
798
+ relatedId: user.id
799
+ });
800
+ if (result?.status === "failed") {
801
+ throw new Error(
802
+ `Verification email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
803
+ );
682
804
  }
683
805
  }
684
806
  }
@@ -691,12 +813,18 @@ var AuthManager = class {
691
813
  updateAge: this.config.session?.updateAge || 60 * 60 * 24
692
814
  // 1 day default
693
815
  },
816
+ // ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
817
+ // so better-auth keeps its own defaults otherwise. The settings bind
818
+ // supplies stricter `customRules` for the auth endpoints.
819
+ ...this.config.rateLimit ? { rateLimit: this.config.rateLimit } : {},
694
820
  // better-auth plugins — registered based on AuthPluginConfig flags
695
821
  plugins,
696
822
  // Database hooks (fired by better-auth's adapter writes — these run
697
823
  // for SSO JIT-provisioning too, unlike kernel-level ObjectQL
698
- // middleware which better-auth's adapter bypasses).
699
- ...this.config.databaseHooks ? { databaseHooks: this.config.databaseHooks } : {},
824
+ // middleware which better-auth's adapter bypasses). The framework's
825
+ // identity-source stamp (`account.create.after`) is always composed in,
826
+ // preserving any host-supplied hooks.
827
+ databaseHooks: this.composeDatabaseHooks(this.config.databaseHooks),
700
828
  // Bootstrap bypass for `disableSignUp`. The first-run owner wizard
701
829
  // (`/_account/setup`) calls `POST /auth/sign-up/email` to create
702
830
  // the very first user — if `OS_DISABLE_SIGNUP=true` is set on a
@@ -707,6 +835,127 @@ var AuthManager = class {
707
835
  // sees `userCount > 0` and the toggle is enforced again.
708
836
  hooks: {
709
837
  before: createAuthMiddleware(async (ctx) => {
838
+ if (ctx?.path === "/sign-up/email" || ctx?.path === "/reset-password" || ctx?.path === "/change-password") {
839
+ const candidate = typeof ctx?.body?.password === "string" && ctx.body.password || typeof ctx?.body?.newPassword === "string" && ctx.body.newPassword || "";
840
+ if (candidate) await this.assertPasswordComplexity(candidate);
841
+ if (candidate && (ctx?.path === "/reset-password" || ctx?.path === "/change-password")) {
842
+ const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => void 0);
843
+ if (userId) {
844
+ ctx.context.__osPwChangeUserId = userId;
845
+ const pw = ctx?.context?.password;
846
+ const verify = typeof pw?.verify === "function" ? pw.verify.bind(pw) : void 0;
847
+ const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
848
+ if (oldHash !== void 0) ctx.context.__osPwHistory = { userId, oldHash };
849
+ }
850
+ }
851
+ }
852
+ if (ctx?.path === "/sso/register") {
853
+ const actor = await this.resolveActor(ctx);
854
+ if (actor?.userId) {
855
+ const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId);
856
+ if (!ok) {
857
+ const { APIError } = await import("better-auth/api");
858
+ throw new APIError("FORBIDDEN", {
859
+ message: "Only an organization owner/admin or a platform admin can register an SSO provider.",
860
+ code: "SSO_REGISTER_FORBIDDEN"
861
+ });
862
+ }
863
+ }
864
+ return;
865
+ }
866
+ if (ctx?.path === "/oauth2/authorize" && this.config.oidcAuthorizeGate) {
867
+ const clientId = ctx?.query?.client_id;
868
+ if (clientId) {
869
+ let gateUserId;
870
+ try {
871
+ const { getSessionFromCtx } = await import("better-auth/api");
872
+ const s = await getSessionFromCtx(ctx);
873
+ gateUserId = s?.user?.id ?? s?.session?.userId;
874
+ } catch {
875
+ }
876
+ if (!gateUserId) {
877
+ try {
878
+ const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
879
+ let token;
880
+ const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
881
+ if (bm?.[1]) token = bm[1].trim();
882
+ if (!token) {
883
+ const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
884
+ if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
885
+ }
886
+ if (token) {
887
+ const sess = await ctx.context.adapter.findOne({
888
+ model: "session",
889
+ where: [{ field: "token", value: token }]
890
+ });
891
+ const exp = sess?.expiresAt ?? sess?.expires_at;
892
+ if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
893
+ gateUserId = String(sess.userId ?? sess.user_id ?? "") || void 0;
894
+ }
895
+ }
896
+ } catch {
897
+ }
898
+ }
899
+ if (gateUserId) {
900
+ const allowed = await this.config.oidcAuthorizeGate({
901
+ userId: gateUserId,
902
+ clientId: String(clientId)
903
+ });
904
+ if (!allowed) {
905
+ const { APIError } = await import("better-auth/api");
906
+ throw new APIError("FORBIDDEN", {
907
+ message: "You are not authorized to sign in to this environment.",
908
+ code: "ENV_ACCESS_DENIED"
909
+ });
910
+ }
911
+ }
912
+ }
913
+ return;
914
+ }
915
+ if (ctx?.path === "/delete-user" || ctx?.path === "/admin/remove-user" || ctx?.path === "/admin/ban-user") {
916
+ let isLastLocalCredential = false;
917
+ try {
918
+ const adapter = ctx.context.adapter;
919
+ let targetId = ctx?.body?.userId ?? ctx?.body?.user_id;
920
+ if (!targetId && ctx.path === "/delete-user") {
921
+ const { getSessionFromCtx } = await import("better-auth/api");
922
+ const s = await getSessionFromCtx(ctx).catch(() => null);
923
+ targetId = s?.user?.id ?? s?.session?.userId;
924
+ }
925
+ if (targetId) {
926
+ const targetCred = await adapter.findOne({
927
+ model: "account",
928
+ where: [
929
+ { field: "userId", value: targetId },
930
+ { field: "providerId", value: "credential" }
931
+ ]
932
+ });
933
+ if (targetCred) {
934
+ const creds = await adapter.findMany({
935
+ model: "account",
936
+ where: [{ field: "providerId", value: "credential" }]
937
+ });
938
+ const otherHolders = new Set(
939
+ (creds ?? []).map((a) => a?.userId ?? a?.user_id).filter((id) => id && id !== targetId)
940
+ );
941
+ isLastLocalCredential = otherHolders.size === 0;
942
+ }
943
+ }
944
+ } catch {
945
+ }
946
+ if (isLastLocalCredential) {
947
+ const { APIError } = await import("better-auth/api");
948
+ throw new APIError("CONFLICT", {
949
+ message: "Cannot remove the last local password login. At least one break-glass account with a password must remain so an identity-provider outage can never lock the organization out. Add another local password first, then retry.",
950
+ code: "LAST_LOCAL_CREDENTIAL"
951
+ });
952
+ }
953
+ }
954
+ if (ctx?.path === "/sign-in/email") {
955
+ const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
956
+ if (email) await this.assertAccountNotLocked(email);
957
+ return;
958
+ }
710
959
  if (ctx?.path !== "/sign-up/email") return;
711
960
  const ep = ctx?.context?.options?.emailAndPassword;
712
961
  if (!ep?.disableSignUp) return;
@@ -721,7 +970,54 @@ var AuthManager = class {
721
970
  }
722
971
  }),
723
972
  after: createAuthMiddleware(async (ctx) => {
973
+ if (ctx?.path === "/sign-in/email") {
974
+ const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
975
+ if (email) {
976
+ let succeeded = true;
977
+ try {
978
+ const { isAPIError } = await import("better-auth/api");
979
+ succeeded = !isAPIError(ctx?.context?.returned);
980
+ } catch {
981
+ succeeded = !(ctx?.context?.returned instanceof Error);
982
+ }
983
+ await this.recordSignInOutcome(email, succeeded);
984
+ if (succeeded) {
985
+ const uid = ctx?.context?.returned?.user?.id;
986
+ if (typeof uid === "string") await this.enforceConcurrentCap(uid);
987
+ }
988
+ }
989
+ return;
990
+ }
991
+ if (ctx?.path === "/change-password" || ctx?.path === "/reset-password") {
992
+ let succeeded;
993
+ try {
994
+ const { isAPIError } = await import("better-auth/api");
995
+ succeeded = !isAPIError(ctx?.context?.returned);
996
+ } catch {
997
+ succeeded = !(ctx?.context?.returned instanceof Error);
998
+ }
999
+ if (succeeded) {
1000
+ const stampId = ctx?.context?.__osPwChangeUserId;
1001
+ if (stampId) await this.stampPasswordChangedAt(stampId);
1002
+ const stash = ctx?.context?.__osPwHistory;
1003
+ if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
1004
+ }
1005
+ delete ctx.context.__osPwChangeUserId;
1006
+ delete ctx.context.__osPwHistory;
1007
+ return;
1008
+ }
724
1009
  if (ctx?.path !== "/sign-up/email") return;
1010
+ {
1011
+ const newUserId = ctx?.context?.returned?.user?.id;
1012
+ let signupOk;
1013
+ try {
1014
+ const { isAPIError } = await import("better-auth/api");
1015
+ signupOk = !isAPIError(ctx?.context?.returned);
1016
+ } catch {
1017
+ signupOk = !(ctx?.context?.returned instanceof Error);
1018
+ }
1019
+ if (signupOk && typeof newUserId === "string") await this.stampPasswordChangedAt(newUserId);
1020
+ }
725
1021
  const ep = ctx?.context?.options?.emailAndPassword;
726
1022
  if (ep && ctx.context.__osDisableSignUpOrig !== void 0) {
727
1023
  ep.disableSignUp = ctx.context.__osDisableSignUpOrig;
@@ -733,7 +1029,7 @@ var AuthManager = class {
733
1029
  // Auto-includes origins from OS_CORS_ORIGIN env var so CORS and CSRF stay in sync.
734
1030
  ...(() => {
735
1031
  const origins = [...this.config.trustedOrigins || []];
736
- const corsOrigin = readEnvWithDeprecation("OS_CORS_ORIGIN", "CORS_ORIGIN");
1032
+ const corsOrigin = readEnvWithDeprecation("OS_CORS_ORIGIN", "CORS_ORIGIN", { silent: true });
737
1033
  if (corsOrigin && corsOrigin !== "*") {
738
1034
  corsOrigin.split(",").map((s) => s.trim()).filter(Boolean).forEach((o) => {
739
1035
  if (!origins.includes(o)) origins.push(o);
@@ -744,6 +1040,20 @@ var AuthManager = class {
744
1040
  origins.push("http://*.localhost:*");
745
1041
  origins.push("https://*.localhost:*");
746
1042
  }
1043
+ if (this.isSsoWired()) {
1044
+ return {
1045
+ trustedOrigins: async (request) => {
1046
+ const base = [...origins];
1047
+ try {
1048
+ for (const o of await this.ssoDiscoveryTrustedOrigins(request)) {
1049
+ if (!base.includes(o)) base.push(o);
1050
+ }
1051
+ } catch {
1052
+ }
1053
+ return base;
1054
+ }
1055
+ };
1056
+ }
747
1057
  return origins.length ? { trustedOrigins: origins } : {};
748
1058
  })(),
749
1059
  // Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
@@ -819,17 +1129,25 @@ var AuthManager = class {
819
1129
  async buildPluginList() {
820
1130
  const pluginConfig = this.config.plugins ?? {};
821
1131
  const plugins = [];
822
- const oidcEnv = globalThis?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
823
- const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === "true" : void 0;
1132
+ const oidcFromEnv = readBooleanEnv("OS_OIDC_PROVIDER_ENABLED");
1133
+ const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
1134
+ const scimFromEnv = readBooleanEnv("OS_SCIM_ENABLED");
1135
+ const ssoDomainVerifyFromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
1136
+ const scimEffective = scimFromEnv ?? pluginConfig.scim ?? false;
824
1137
  const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
1138
+ const hibpFromEnv = readBooleanEnv("OS_AUTH_PASSWORD_REJECT_BREACHED");
825
1139
  const enabled = {
826
1140
  organization: pluginConfig.organization ?? true,
827
1141
  twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false,
1142
+ passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false,
828
1143
  passkeys: pluginConfig.passkeys ?? false,
829
1144
  magicLink: pluginConfig.magicLink ?? false,
830
1145
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
831
1146
  deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
832
- admin: pluginConfig.admin ?? false
1147
+ admin: pluginConfig.admin ?? scimEffective,
1148
+ sso: ssoFromEnv ?? pluginConfig.sso ?? false,
1149
+ ssoDomainVerification: ssoDomainVerifyFromEnv ?? pluginConfig.ssoDomainVerification ?? false,
1150
+ scim: scimEffective
833
1151
  };
834
1152
  const { bearer } = await import("better-auth/plugins/bearer");
835
1153
  plugins.push(bearer());
@@ -872,6 +1190,28 @@ var AuthManager = class {
872
1190
  // the built-in /accept-invitation route usable for pilots; operators
873
1191
  // who wire a real mailer can re-enable downstream.
874
1192
  requireEmailVerificationOnInvitation: false,
1193
+ // Cap how many orgs a user can CREATE (OS_ORG_LIMIT). Counts only orgs
1194
+ // the user OWNS (role=owner) — never orgs they were merely invited into —
1195
+ // so a generous cap stops scripted org/free-env spam (each new org can
1196
+ // auto-provision a free environment on the cloud control plane) WITHOUT
1197
+ // ever blocking a collaborator who belongs to many orgs. Unset → no
1198
+ // limit (self-host default). Fail-open: if the count can't be taken we
1199
+ // allow creation rather than block a legitimate user on an infra hiccup.
1200
+ organizationLimit: async (user) => {
1201
+ const limit = resolveOrgLimit();
1202
+ if (limit == null) return false;
1203
+ const engine = this.config.dataEngine;
1204
+ const uid = typeof user?.id === "string" ? user.id : "";
1205
+ if (!engine || !uid) return false;
1206
+ try {
1207
+ const owned = await withSystemReadContext(engine).count("sys_member", {
1208
+ where: { user_id: uid, role: "owner" }
1209
+ });
1210
+ return (owned ?? 0) >= limit;
1211
+ } catch {
1212
+ return false;
1213
+ }
1214
+ },
875
1215
  ...customOrgRoles ? { roles: customOrgRoles } : {},
876
1216
  // ── Slug-change guard ─────────────────────────────────────
877
1217
  // An org's slug is baked into every env hostname at creation
@@ -890,21 +1230,35 @@ var AuthManager = class {
890
1230
  // The plugin itself is always installed (so list/update/invite endpoints
891
1231
  // keep responding); only the `create` operation is denied when the
892
1232
  // deployment is provisioned in single-org mode. Resolution order:
893
- // 1. explicit `OS_MULTI_ORG_ENABLED` (wins for backwards compat),
894
- // 2. else `OS_MULTI_TENANT` (multi-tenant deployments are always
895
- // multi-org), default `'false'` → single-org / per-env runtime.
1233
+ // `OS_MULTI_ORG_ENABLED` (default `'false'` single-org /
1234
+ // per-env runtime).
896
1235
  beforeCreateOrganization: async () => {
897
- const env = globalThis?.process?.env ?? {};
898
- const explicit = env.OS_MULTI_ORG_ENABLED;
899
- const legacy = explicit === void 0 ? readEnvWithDeprecation("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") : explicit;
900
- const flag = String(legacy ?? "false").toLowerCase();
901
- if (flag === "false") {
1236
+ if (!resolveMultiOrgEnabled()) {
902
1237
  const { APIError } = await import("better-auth/api");
903
1238
  throw new APIError("FORBIDDEN", {
904
1239
  message: "Creating additional organizations is disabled on this deployment."
905
1240
  });
906
1241
  }
907
1242
  },
1243
+ // Run host-provided org-creation side effects (e.g. the cloud control
1244
+ // plane provisions the org's born-with production environment). The
1245
+ // org-plugin's models don't fire core databaseHooks, so this is the
1246
+ // only server-side seam for "every org is born with its prod env".
1247
+ // Failure-isolated: org creation must not roll back on a side-effect miss.
1248
+ afterCreateOrganization: async ({ organization: organization2, member, user }) => {
1249
+ const cb = this.config.onOrganizationCreated;
1250
+ if (typeof cb !== "function") return;
1251
+ try {
1252
+ await cb({
1253
+ organizationId: organization2?.id,
1254
+ userId: user?.id ?? member?.userId,
1255
+ name: organization2?.name,
1256
+ slug: organization2?.slug
1257
+ });
1258
+ } catch (err) {
1259
+ console.warn("[auth] onOrganizationCreated callback failed:", err?.message ?? String(err));
1260
+ }
1261
+ },
908
1262
  beforeUpdateOrganization: async ({ organization: organization2, member }) => {
909
1263
  const newSlug = organization2?.slug;
910
1264
  const orgId = member?.organizationId;
@@ -982,6 +1336,12 @@ var AuthManager = class {
982
1336
  schema: buildTwoFactorPluginSchema()
983
1337
  }));
984
1338
  }
1339
+ if (enabled.passwordRejectBreached) {
1340
+ const { haveIBeenPwned } = await import("better-auth/plugins/haveibeenpwned");
1341
+ plugins.push(haveIBeenPwned({
1342
+ customPasswordCompromisedMessage: "This password has appeared in a known data breach. Please choose a different one."
1343
+ }));
1344
+ }
985
1345
  if (enabled.admin) {
986
1346
  const { admin } = await import("better-auth/plugins/admin");
987
1347
  plugins.push(admin({
@@ -1049,6 +1409,17 @@ var AuthManager = class {
1049
1409
  schema: buildOauthProviderPluginSchema()
1050
1410
  }));
1051
1411
  }
1412
+ if (enabled.sso) {
1413
+ const { sso } = await import("@better-auth/sso");
1414
+ plugins.push(sso({
1415
+ organizationProvisioning: { defaultRole: "member" },
1416
+ ...enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}
1417
+ }));
1418
+ }
1419
+ if (enabled.scim) {
1420
+ const { scim } = await import("@better-auth/scim");
1421
+ plugins.push(scim({ storeSCIMToken: "hashed" }));
1422
+ }
1052
1423
  if (enabled.deviceAuthorization) {
1053
1424
  const { deviceAuthorization } = await import("better-auth/plugins/device-authorization");
1054
1425
  const baseUrl = (this.config.baseUrl ?? "").replace(/\/$/, "");
@@ -1085,30 +1456,45 @@ var AuthManager = class {
1085
1456
  return false;
1086
1457
  }
1087
1458
  };
1088
- const isActiveOrgAdmin = async () => {
1459
+ const activeOrgRoles = async () => {
1089
1460
  try {
1090
1461
  const orgId = session?.activeOrganizationId;
1091
- if (!orgId) return false;
1462
+ if (!orgId) return [];
1092
1463
  const members = await dataEngine.find("sys_member", {
1093
1464
  where: { user_id: user.id, organization_id: orgId },
1094
1465
  limit: 5
1095
1466
  });
1096
- return (Array.isArray(members) ? members : []).some((m) => {
1467
+ const out = [];
1468
+ for (const m of Array.isArray(members) ? members : []) {
1097
1469
  const raw = typeof m?.role === "string" ? m.role : "";
1098
- const roles2 = raw.split(",").map((s) => s.trim().toLowerCase());
1099
- return roles2.includes("owner") || roles2.includes("admin");
1100
- });
1470
+ for (const r of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
1471
+ const mapped = mapMembershipRole(r);
1472
+ if (!out.includes(mapped)) out.push(mapped);
1473
+ }
1474
+ }
1475
+ return out;
1101
1476
  } catch {
1102
- return false;
1477
+ return [];
1103
1478
  }
1104
1479
  };
1105
1480
  const platformAdmin = await isPlatformAdmin();
1106
- const promote = platformAdmin || await isActiveOrgAdmin();
1481
+ const orgRoles = await activeOrgRoles();
1107
1482
  const storedRole = typeof user.role === "string" ? user.role : "";
1108
- const roles = storedRole.split(",").map((s) => s.trim()).filter(Boolean);
1109
- if (promote && !roles.includes("admin")) roles.push("admin");
1110
- if (!promote) return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
1111
- return { user: { ...user, role: "admin", roles, isPlatformAdmin: platformAdmin }, session };
1483
+ const roles = Array.from(/* @__PURE__ */ new Set([
1484
+ ...storedRole.split(",").map((s) => s.trim()).filter(Boolean),
1485
+ ...orgRoles,
1486
+ ...platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []
1487
+ ]));
1488
+ await this.enforceSessionControls(session?.id, session?.createdAt);
1489
+ const authGate = await this.computeAuthGate(
1490
+ user.id,
1491
+ session?.activeOrganizationId,
1492
+ user?.twoFactorEnabled === true
1493
+ );
1494
+ return {
1495
+ user: { ...user, roles, isPlatformAdmin: platformAdmin, ...authGate ? { authGate } : {} },
1496
+ session
1497
+ };
1112
1498
  }));
1113
1499
  }
1114
1500
  return plugins;
@@ -1138,7 +1524,7 @@ var AuthManager = class {
1138
1524
  * Generate a secure secret if not provided
1139
1525
  */
1140
1526
  generateSecret() {
1141
- const envSecret = readEnvWithDeprecation("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"]);
1527
+ const envSecret = readEnvWithDeprecation("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"], { silent: true });
1142
1528
  if (envSecret) return envSecret;
1143
1529
  if (process.env.NODE_ENV === "production") {
1144
1530
  throw new Error(
@@ -1307,6 +1693,18 @@ var AuthManager = class {
1307
1693
  // `sys_device_code`. Enable via `plugins.deviceAuthorization: true` in
1308
1694
  // AuthPluginConfig.
1309
1695
  // ---------------------------------------------------------------------------
1696
+ /**
1697
+ * SSO-only ("enforced") login mode: the login UI hides the local password
1698
+ * form + self-registration so the team signs in via the IdP only.
1699
+ * `OS_AUTH_SSO_ONLY` (when set) wins over the `ssoOnlyMode` config knob —
1700
+ * parity with the `disableSignUp` env override — so a deployment can force
1701
+ * it regardless of the per-env/config value. Break-glass is preserved: this
1702
+ * NEVER disables `emailAndPassword.enabled`; it only forces `disableSignUp`
1703
+ * and signals the UI to hide the password form. Generic over the IdP.
1704
+ */
1705
+ resolveSsoOnly() {
1706
+ return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false);
1707
+ }
1310
1708
  getPublicConfig() {
1311
1709
  const socialProviders = [];
1312
1710
  if (this.config.socialProviders) {
@@ -1344,16 +1742,15 @@ var AuthManager = class {
1344
1742
  }
1345
1743
  const emailPasswordConfig = this.config.emailAndPassword ?? {};
1346
1744
  const disableSignUpFromEnv = readDisableSignUpEnv();
1745
+ const ssoOnly = this.resolveSsoOnly();
1347
1746
  const emailPassword = {
1348
1747
  enabled: emailPasswordConfig.enabled !== false,
1349
1748
  // Default to true
1350
- disableSignUp: disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
1749
+ disableSignUp: ssoOnly ? true : disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
1351
1750
  requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false
1352
1751
  };
1353
1752
  const pluginConfig = this.config.plugins ?? {};
1354
- const multiOrgEnv = globalThis?.process?.env ?? {};
1355
- const multiOrgRaw = multiOrgEnv.OS_MULTI_ORG_ENABLED !== void 0 ? multiOrgEnv.OS_MULTI_ORG_ENABLED : readEnvWithDeprecation("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") ?? "false";
1356
- const multiOrgEnabled = String(multiOrgRaw).toLowerCase() !== "false";
1753
+ const multiOrgEnabled = resolveMultiOrgEnabled();
1357
1754
  const DEFAULT_TERMS_URL = "https://objectstack.ai/terms";
1358
1755
  const DEFAULT_PRIVACY_URL = "https://objectstack.ai/privacy";
1359
1756
  const rawTermsUrl = globalThis?.process?.env?.OS_TERMS_URL;
@@ -1376,6 +1773,15 @@ var AuthManager = class {
1376
1773
  organization: pluginConfig.organization ?? true,
1377
1774
  multiOrgEnabled,
1378
1775
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
1776
+ // Coarse "is the @better-auth/sso plugin wired" flag. The `/auth/config`
1777
+ // route refines this to "usable" (≥1 provider configured) via
1778
+ // `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button
1779
+ // both when SSO is off AND when it's on but no IdP exists yet.
1780
+ sso: this.isSsoWired(),
1781
+ // SSO-only ("enforced"): tell the login UI to hide the local password
1782
+ // form + self-registration. A break-glass "use a password" link remains
1783
+ // for the env owner / local admin. Driven by `ssoOnlyMode` / `OS_AUTH_SSO_ONLY`.
1784
+ ssoEnforced: ssoOnly,
1379
1785
  deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
1380
1786
  admin: pluginConfig.admin ?? false,
1381
1787
  ...termsUrl ? { termsUrl } : {},
@@ -1387,6 +1793,712 @@ var AuthManager = class {
1387
1793
  features
1388
1794
  };
1389
1795
  }
1796
+ /**
1797
+ * Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag.
1798
+ * Resolved with the EXACT logic that decides whether the plugin is mounted
1799
+ * in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
1800
+ * advertised capability can never disagree with the actual `/sign-in/sso`
1801
+ * route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
1802
+ * Public so `AuthPlugin` can gate the Setup-nav "SSO Providers" entry on it
1803
+ * (captures both self-host `OS_SSO_ENABLED` and the cloud per-env
1804
+ * `planAllowsSso` config, since that arrives via `plugins.sso`).
1805
+ */
1806
+ isSsoWired() {
1807
+ const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
1808
+ return ssoFromEnv ?? this.config.plugins?.sso ?? false;
1809
+ }
1810
+ /**
1811
+ * Whether opt-in DNS domain-verification (ADR-0024 ②) is wired — i.e. the
1812
+ * `/sso/request-domain-verification` + `/sso/verify-domain` endpoints are
1813
+ * mounted (and the hard "domain must be verified to log in" gate is active).
1814
+ * Resolved with the EXACT logic `buildPluginList` uses for the `sso()`
1815
+ * `domainVerification.enabled` option, so the bridge can return a clear
1816
+ * "not enabled for this environment" instead of a bare 404 when off.
1817
+ * Implies `isSsoWired()` (the sso plugin must be loaded to honor it).
1818
+ */
1819
+ isSsoDomainVerificationEnabled() {
1820
+ if (!this.isSsoWired()) return false;
1821
+ const fromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
1822
+ return fromEnv ?? this.config.plugins?.ssoDomainVerification ?? false;
1823
+ }
1824
+ /**
1825
+ * Whether enterprise SSO is actually *usable*, not merely wired: the plugin
1826
+ * is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
1827
+ * matching still happens at `/sign-in/sso`; this answers the coarser "is
1828
+ * there any point showing the SSO button at all", so a freshly-enabled but
1829
+ * unconfigured SSO setup doesn't advertise a button that errors for everyone.
1830
+ *
1831
+ * Fails OPEN to the wired flag when providers can't be counted (no data
1832
+ * engine, query error) — a config-introspection hiccup must never make the
1833
+ * login page hide a button that genuinely works.
1834
+ */
1835
+ async isSsoUsable() {
1836
+ if (!this.isSsoWired()) return false;
1837
+ const engine = this.getDataEngine();
1838
+ if (!engine) return true;
1839
+ try {
1840
+ const count = await withSystemReadContext(engine).count("sys_sso_provider");
1841
+ return typeof count === "number" ? count > 0 : true;
1842
+ } catch {
1843
+ return true;
1844
+ }
1845
+ }
1846
+ /**
1847
+ * Extra `trustedOrigins` entries derived from an external-SSO registration
1848
+ * request. For a `POST /sso/register` | `/sso/update-provider`, parse the
1849
+ * (cloned) body and return the PUBLIC-ROUTABLE origins of the declared
1850
+ * `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery
1851
+ * validation accepts a customer IdP registered at runtime (ADR-0024) without
1852
+ * the operator pre-listing it in boot config. Only public-routable hosts are
1853
+ * returned — private / internal / loopback hosts are never auto-trusted
1854
+ * (better-auth's `isPublicRoutableHost`, the same predicate its own
1855
+ * sub-endpoint check uses). Best-effort: any parse error yields `[]`.
1856
+ */
1857
+ async ssoDiscoveryTrustedOrigins(request) {
1858
+ try {
1859
+ const req = request;
1860
+ if (!req || typeof req.clone !== "function" || !req.url) return [];
1861
+ if ((req.method ?? "GET").toUpperCase() !== "POST") return [];
1862
+ const path = new URL(req.url).pathname;
1863
+ if (!/\/sso\/(register|update-provider)$/.test(path)) return [];
1864
+ const body = await req.clone().json().catch(() => null);
1865
+ if (!body || typeof body !== "object") return [];
1866
+ const oidc = body.oidcConfig ?? {};
1867
+ const candidates = [
1868
+ body.issuer,
1869
+ oidc.discoveryEndpoint,
1870
+ oidc.authorizationEndpoint,
1871
+ oidc.tokenEndpoint,
1872
+ oidc.jwksEndpoint,
1873
+ oidc.userInfoEndpoint
1874
+ ].filter((v) => typeof v === "string" && v.length > 0);
1875
+ if (!candidates.length) return [];
1876
+ const { isPublicRoutableHost } = await import("@better-auth/core/utils/host");
1877
+ const out = [];
1878
+ for (const c of candidates) {
1879
+ try {
1880
+ const u = new URL(c);
1881
+ if (isPublicRoutableHost(u.hostname) && !out.includes(u.origin)) out.push(u.origin);
1882
+ } catch {
1883
+ }
1884
+ }
1885
+ return out;
1886
+ } catch {
1887
+ return [];
1888
+ }
1889
+ }
1890
+ /**
1891
+ * Resolve the acting user (+ their active org) for a before-hook gate,
1892
+ * hook-order-independent. Tries the standard cookie session first, then falls
1893
+ * back to explicit token resolution (bearer or the session cookie's token
1894
+ * part) — the bearer plugin may convert `Authorization: Bearer` to a session
1895
+ * AFTER this global before-hook runs. Returns `null` when no valid session
1896
+ * can be resolved (→ caller lets `sessionMiddleware` issue the 401).
1897
+ */
1898
+ async resolveActor(ctx) {
1899
+ try {
1900
+ const { getSessionFromCtx } = await import("better-auth/api");
1901
+ const s = await getSessionFromCtx(ctx);
1902
+ const userId = s?.user?.id ?? s?.session?.userId;
1903
+ if (userId) {
1904
+ return {
1905
+ userId: String(userId),
1906
+ activeOrgId: s?.session?.activeOrganizationId ?? s?.activeOrganizationId ?? void 0
1907
+ };
1908
+ }
1909
+ } catch {
1910
+ }
1911
+ try {
1912
+ const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
1913
+ let token;
1914
+ const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
1915
+ if (bm?.[1]) token = bm[1].trim();
1916
+ if (!token) {
1917
+ const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
1918
+ if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
1919
+ }
1920
+ if (token) {
1921
+ const sess = await ctx.context.adapter.findOne({
1922
+ model: "session",
1923
+ where: [{ field: "token", value: token }]
1924
+ });
1925
+ const exp = sess?.expiresAt ?? sess?.expires_at;
1926
+ if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
1927
+ const userId = String(sess.userId ?? sess.user_id ?? "");
1928
+ if (userId) {
1929
+ return {
1930
+ userId,
1931
+ activeOrgId: sess.activeOrganizationId ?? sess.active_organization_id ?? void 0
1932
+ };
1933
+ }
1934
+ }
1935
+ }
1936
+ } catch {
1937
+ }
1938
+ return null;
1939
+ }
1940
+ /**
1941
+ * True when `userId` is a platform admin (a `sys_user_permission_set` row
1942
+ * pointing at `admin_full_access` with `organization_id = null`) OR an
1943
+ * owner/admin member of `activeOrgId` (any org membership with role
1944
+ * owner/admin when no active org is set). Mirrors the role-derivation in
1945
+ * `customSession`; reads through `withSystemReadContext` so the lookups are
1946
+ * not themselves RLS-scoped to the acting (possibly non-privileged) user.
1947
+ * Fails CLOSED (returns false) on any lookup error — this backs a security
1948
+ * gate, so an unverifiable actor must never pass.
1949
+ */
1950
+ async isOrgOrPlatformAdmin(userId, activeOrgId) {
1951
+ const engine = this.getDataEngine();
1952
+ if (!engine) return false;
1953
+ const sys = withSystemReadContext(engine);
1954
+ try {
1955
+ const links = await sys.find("sys_user_permission_set", {
1956
+ where: { user_id: userId },
1957
+ limit: 50
1958
+ });
1959
+ const platformLinks = (Array.isArray(links) ? links : []).filter(
1960
+ (l) => !l.organization_id
1961
+ );
1962
+ if (platformLinks.length) {
1963
+ const sets = await sys.find("sys_permission_set", { limit: 50 });
1964
+ const adminSet = (Array.isArray(sets) ? sets : []).find(
1965
+ (r) => r.name === "admin_full_access"
1966
+ );
1967
+ if (adminSet && platformLinks.some((l) => l.permission_set_id === adminSet.id)) {
1968
+ return true;
1969
+ }
1970
+ }
1971
+ const where = { user_id: userId };
1972
+ if (activeOrgId) where.organization_id = activeOrgId;
1973
+ const members = await sys.find("sys_member", { where, limit: 10 });
1974
+ for (const m of Array.isArray(members) ? members : []) {
1975
+ const raw = typeof m?.role === "string" ? m.role : "";
1976
+ if (raw.split(",").map((s) => s.trim()).some((r) => r === "owner" || r === "admin")) {
1977
+ return true;
1978
+ }
1979
+ }
1980
+ return false;
1981
+ } catch {
1982
+ return false;
1983
+ }
1984
+ }
1985
+ /**
1986
+ * Compose the framework's identity-source stamp (`account.create.after`)
1987
+ * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes
1988
+ * `user.create.after` (personal-org provisioning) + `session.create.before`
1989
+ * (active-org) — different model/op, so no collision — but if a host ever
1990
+ * adds its own `account.create.after` we chain it after the stamp rather
1991
+ * than silently dropping one.
1992
+ */
1993
+ composeDatabaseHooks(host) {
1994
+ const stamp = (account, ctx) => this.stampIdentitySource(account, ctx);
1995
+ const hostAccountAfter = host?.account?.create?.after;
1996
+ const after = hostAccountAfter ? async (account, ctx) => {
1997
+ await stamp(account, ctx);
1998
+ return hostAccountAfter(account, ctx);
1999
+ } : stamp;
2000
+ return {
2001
+ ...host ?? {},
2002
+ account: {
2003
+ ...host?.account ?? {},
2004
+ create: {
2005
+ ...host?.account?.create ?? {},
2006
+ after
2007
+ }
2008
+ }
2009
+ };
2010
+ }
2011
+ /**
2012
+ * Maintain `sys_user.source` (ADR-0024 D4 provenance) as accounts are linked.
2013
+ * Drives the managed-vs-native user-mgmt gating: a managed (`idp-provisioned`)
2014
+ * user holds no local credential, so the password / identity-edit actions
2015
+ * hide for them — preventing a managed user from self-minting a local
2016
+ * password that would bypass enforced SSO.
2017
+ *
2018
+ * Two cases, both break-glass safe and idempotent (only writes on a real
2019
+ * change, so trackHistory stays quiet):
2020
+ *
2021
+ * • A **federated** account (any non-`credential` provider — the cloud-as-IdP
2022
+ * `objectstack-cloud` provider OR a customer's own OIDC/SAML IdP) is
2023
+ * linked AND the user holds NO local credential → mark `idp-provisioned`.
2024
+ * A user who already has a `credential` account (an env-native user who
2025
+ * linked SSO) is left `env-native` — they keep a usable password.
2026
+ *
2027
+ * • A **credential** account is created (local signup, or the break-glass
2028
+ * owner's password set via set-initial-password — which can land AFTER the
2029
+ * first SSO link) → ensure `env-native`. This flips a previously-stamped
2030
+ * owner back, so the break-glass admin never loses self-service password
2031
+ * management.
2032
+ *
2033
+ * Best-effort: any failure leaves the prior value (the gate fails open — a
2034
+ * managed user might transiently show a password action that simply errors —
2035
+ * never a hard login failure).
2036
+ */
2037
+ async stampIdentitySource(account, _ctx) {
2038
+ try {
2039
+ const providerId = account?.providerId ?? account?.provider_id;
2040
+ const userId = account?.userId ?? account?.user_id;
2041
+ if (!userId || !providerId) return;
2042
+ const engine = this.getDataEngine();
2043
+ if (!engine) return;
2044
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2045
+ if (providerId === "credential") {
2046
+ const u = await engine.findOne("sys_user", {
2047
+ filter: { id: userId },
2048
+ fields: ["id", "source"],
2049
+ context: SYSTEM_CTX
2050
+ });
2051
+ if (u && u.source === "idp_provisioned") {
2052
+ await engine.update("sys_user", { id: userId, source: "env_native" }, { context: SYSTEM_CTX });
2053
+ }
2054
+ return;
2055
+ }
2056
+ const credentialCount = await engine.count("sys_account", {
2057
+ filter: { user_id: userId, provider_id: "credential" },
2058
+ context: SYSTEM_CTX
2059
+ });
2060
+ if (typeof credentialCount === "number" && credentialCount > 0) return;
2061
+ await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
2062
+ } catch {
2063
+ }
2064
+ }
2065
+ /**
2066
+ * ADR-0069 D1 — reject a password that doesn't meet the configured character-
2067
+ * class complexity. No-op when `passwordRequireComplexity` is off. Counts the
2068
+ * four classes (upper / lower / digit / symbol) present and throws
2069
+ * `PASSWORD_POLICY_VIOLATION` when fewer than `passwordMinClasses` are used.
2070
+ */
2071
+ async assertPasswordComplexity(password) {
2072
+ if (!this.config.passwordRequireComplexity) return;
2073
+ const min = Math.min(4, Math.max(1, Math.floor(Number(this.config.passwordMinClasses) || 3)));
2074
+ const classes = (/[a-z]/.test(password) ? 1 : 0) + (/[A-Z]/.test(password) ? 1 : 0) + (/[0-9]/.test(password) ? 1 : 0) + (/[^A-Za-z0-9]/.test(password) ? 1 : 0);
2075
+ if (classes < min) {
2076
+ const { APIError } = await import("better-auth/api");
2077
+ throw new APIError("BAD_REQUEST", {
2078
+ message: `Password must include at least ${min} of: uppercase, lowercase, digit, symbol.`,
2079
+ code: "PASSWORD_POLICY_VIOLATION"
2080
+ });
2081
+ }
2082
+ }
2083
+ /**
2084
+ * ADR-0069 — is any authentication-policy gate enabled? Cheap, synchronous;
2085
+ * lets the transport seams skip session lookups entirely when off (the
2086
+ * default), keeping the gate zero-overhead until an admin opts in.
2087
+ */
2088
+ isAuthGateActive() {
2089
+ this.refreshOrgMfaCacheIfStale();
2090
+ return Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || this.config.mfaRequired === true || this._orgMfaCache.value;
2091
+ }
2092
+ /**
2093
+ * ADR-0069 — refresh the "any org requires MFA" cache in the background when
2094
+ * stale (60s TTL). Fire-and-forget: a brand-new per-org requirement activates
2095
+ * the gate on the next request, never blocking this one. No-op when global MFA
2096
+ * is already on (the gate is active regardless).
2097
+ */
2098
+ refreshOrgMfaCacheIfStale() {
2099
+ if (this.config.mfaRequired === true) return;
2100
+ if (this._orgMfaRefreshing) return;
2101
+ if (Date.now() - this._orgMfaCache.at < 6e4) return;
2102
+ const engine = this.getDataEngine();
2103
+ if (!engine) return;
2104
+ this._orgMfaRefreshing = true;
2105
+ void (async () => {
2106
+ try {
2107
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2108
+ const n = await engine.count("sys_organization", {
2109
+ where: { require_mfa: true },
2110
+ context: SYSTEM_CTX
2111
+ });
2112
+ this._orgMfaCache = { value: typeof n === "number" && n > 0, at: Date.now() };
2113
+ } catch {
2114
+ } finally {
2115
+ this._orgMfaRefreshing = false;
2116
+ }
2117
+ })();
2118
+ }
2119
+ /**
2120
+ * ADR-0069 — compute the auth-policy gate posture for a session. Returns an
2121
+ * `{ code, message }` when the user is currently blocked (e.g. password
2122
+ * expired), else undefined. No-op (and no DB read) when no gate feature is
2123
+ * enabled. Fails OPEN on any lookup error — a transient hiccup must never lock
2124
+ * a compliant user out.
2125
+ */
2126
+ async computeAuthGate(userId, _activeOrgId, _twoFactorEnabledHint) {
2127
+ const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0);
2128
+ const mfaGlobal = this.config.mfaRequired === true;
2129
+ const orgMaybeRequires = !mfaGlobal && !!_activeOrgId && this._orgMfaCache.value;
2130
+ if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires) return void 0;
2131
+ const engine = this.getDataEngine();
2132
+ if (!engine || !userId) return void 0;
2133
+ try {
2134
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2135
+ const u = await engine.findOne("sys_user", {
2136
+ where: { id: userId },
2137
+ fields: ["password_changed_at", "two_factor_enabled", "mfa_required_at"],
2138
+ context: SYSTEM_CTX
2139
+ });
2140
+ let mfaRequired = mfaGlobal;
2141
+ if (!mfaRequired && orgMaybeRequires) {
2142
+ const org = await engine.findOne("sys_organization", {
2143
+ where: { id: _activeOrgId },
2144
+ fields: ["require_mfa"],
2145
+ context: SYSTEM_CTX
2146
+ });
2147
+ mfaRequired = org?.require_mfa === true || org?.require_mfa === 1;
2148
+ }
2149
+ if (expiryDays > 0) {
2150
+ const changed = u?.password_changed_at;
2151
+ if (changed && Date.now() - new Date(changed).getTime() > expiryDays * 864e5) {
2152
+ return {
2153
+ code: "PASSWORD_EXPIRED",
2154
+ message: "Your password has expired. Please change it to continue."
2155
+ };
2156
+ }
2157
+ }
2158
+ if (mfaRequired && !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1)) {
2159
+ const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7)));
2160
+ let requiredAt = u?.mfa_required_at;
2161
+ if (!requiredAt) {
2162
+ requiredAt = /* @__PURE__ */ new Date();
2163
+ engine.update("sys_user", { id: userId, mfa_required_at: requiredAt }, { context: SYSTEM_CTX }).catch(() => void 0);
2164
+ }
2165
+ const elapsedMs = Date.now() - new Date(requiredAt).getTime();
2166
+ if (elapsedMs > graceDays * 864e5) {
2167
+ return {
2168
+ code: "MFA_REQUIRED",
2169
+ message: "Multi-factor authentication is required. Please set up an authenticator app to continue."
2170
+ };
2171
+ }
2172
+ }
2173
+ } catch {
2174
+ return void 0;
2175
+ }
2176
+ return void 0;
2177
+ }
2178
+ /**
2179
+ * ADR-0069 D1 — stamp `sys_user.password_changed_at = now` after a password is
2180
+ * set (sign-up / change / reset). Best-effort; never throws. Written as a Date
2181
+ * (never epoch-ms) per ADR-0074.
2182
+ */
2183
+ async stampPasswordChangedAt(userId) {
2184
+ const engine = this.getDataEngine();
2185
+ if (!engine || !userId) return;
2186
+ try {
2187
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2188
+ await engine.update(
2189
+ "sys_user",
2190
+ { id: userId, password_changed_at: /* @__PURE__ */ new Date() },
2191
+ { context: SYSTEM_CTX }
2192
+ );
2193
+ } catch {
2194
+ }
2195
+ }
2196
+ /**
2197
+ * ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
2198
+ * a string[] of hashes, tolerating null / malformed values.
2199
+ */
2200
+ parseHashes(raw) {
2201
+ if (typeof raw !== "string" || !raw.trim()) return [];
2202
+ try {
2203
+ const arr = JSON.parse(raw);
2204
+ return Array.isArray(arr) ? arr.filter((h) => typeof h === "string" && !!h) : [];
2205
+ } catch {
2206
+ return [];
2207
+ }
2208
+ }
2209
+ /**
2210
+ * ADR-0069 D1 — resolve the user whose password is being changed. For
2211
+ * `/change-password` the caller is authenticated (session); for
2212
+ * `/reset-password` the user is carried by the reset token's verification
2213
+ * value (the same lookup better-auth's own handler uses).
2214
+ */
2215
+ async resolvePasswordChangeUserId(ctx) {
2216
+ if (ctx?.path === "/change-password") {
2217
+ const { getSessionFromCtx } = await import("better-auth/api");
2218
+ const sess = await getSessionFromCtx(ctx).catch(() => null);
2219
+ return sess?.user?.id ?? sess?.session?.userId ?? void 0;
2220
+ }
2221
+ if (ctx?.path === "/reset-password") {
2222
+ const token = typeof ctx?.body?.token === "string" ? ctx.body.token : "";
2223
+ if (!token) return void 0;
2224
+ try {
2225
+ const v = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
2226
+ const raw = v?.value;
2227
+ if (!raw) return void 0;
2228
+ if (typeof raw === "string") {
2229
+ const t = raw.trim();
2230
+ if (t.startsWith("{") || t.startsWith('"')) {
2231
+ try {
2232
+ const o = JSON.parse(t);
2233
+ return (typeof o === "string" ? o : o?.userId) ?? void 0;
2234
+ } catch {
2235
+ return t;
2236
+ }
2237
+ }
2238
+ return t;
2239
+ }
2240
+ return raw?.userId ?? void 0;
2241
+ } catch {
2242
+ return void 0;
2243
+ }
2244
+ }
2245
+ return void 0;
2246
+ }
2247
+ /**
2248
+ * ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's
2249
+ * current password or any hash in the bounded history. Reuses better-auth's
2250
+ * native `password.verify` (passed in) rather than re-hashing. Returns the
2251
+ * current hash (for the after-hook to append) when the candidate is fresh, or
2252
+ * undefined when the feature is off / nothing to compare.
2253
+ */
2254
+ async assertPasswordNotReused(userId, candidate, verify) {
2255
+ const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2256
+ if (count <= 0 || typeof verify !== "function") return void 0;
2257
+ const engine = this.getDataEngine();
2258
+ if (!engine) return void 0;
2259
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2260
+ let account;
2261
+ try {
2262
+ account = await engine.findOne("sys_account", {
2263
+ where: { user_id: userId, provider_id: "credential" },
2264
+ fields: ["id", "password", "previous_password_hashes"],
2265
+ context: SYSTEM_CTX
2266
+ });
2267
+ } catch {
2268
+ return void 0;
2269
+ }
2270
+ if (!account?.id) return void 0;
2271
+ const currentHash = typeof account.password === "string" ? account.password : "";
2272
+ const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean);
2273
+ for (const h of compareList) {
2274
+ let match = false;
2275
+ try {
2276
+ match = await verify({ password: candidate, hash: h });
2277
+ } catch {
2278
+ match = false;
2279
+ }
2280
+ if (match) {
2281
+ const { APIError } = await import("better-auth/api");
2282
+ throw new APIError("BAD_REQUEST", {
2283
+ message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`,
2284
+ code: "PASSWORD_REUSE"
2285
+ });
2286
+ }
2287
+ }
2288
+ return currentHash;
2289
+ }
2290
+ /**
2291
+ * ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a
2292
+ * successful change/reset. Best-effort; never throws.
2293
+ */
2294
+ async recordPasswordHistory(userId, oldHash) {
2295
+ const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2296
+ if (count <= 0 || !oldHash) return;
2297
+ const engine = this.getDataEngine();
2298
+ if (!engine) return;
2299
+ try {
2300
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2301
+ const account = await engine.findOne("sys_account", {
2302
+ where: { user_id: userId, provider_id: "credential" },
2303
+ fields: ["id", "previous_password_hashes"],
2304
+ context: SYSTEM_CTX
2305
+ });
2306
+ if (!account?.id) return;
2307
+ const prev = this.parseHashes(account.previous_password_hashes);
2308
+ const next = [oldHash, ...prev.filter((h) => h !== oldHash)].slice(0, count);
2309
+ await engine.update(
2310
+ "sys_account",
2311
+ { id: account.id, previous_password_hashes: JSON.stringify(next) },
2312
+ { context: SYSTEM_CTX }
2313
+ );
2314
+ } catch {
2315
+ }
2316
+ }
2317
+ /**
2318
+ * ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
2319
+ * out (brute-force protection). No-op when lockout is disabled
2320
+ * (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a
2321
+ * lookup error: an infra hiccup must never block every login.
2322
+ */
2323
+ async assertAccountNotLocked(email) {
2324
+ const threshold = Number(this.config.lockoutThreshold) || 0;
2325
+ if (threshold <= 0) return;
2326
+ const engine = this.getDataEngine();
2327
+ if (!engine) return;
2328
+ let locked = false;
2329
+ try {
2330
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2331
+ const u = await engine.findOne("sys_user", {
2332
+ where: { email },
2333
+ fields: ["id", "locked_until"],
2334
+ context: SYSTEM_CTX
2335
+ });
2336
+ const lu = u?.locked_until;
2337
+ locked = !!(lu && new Date(lu).getTime() > Date.now());
2338
+ } catch {
2339
+ return;
2340
+ }
2341
+ if (locked) {
2342
+ const { APIError } = await import("better-auth/api");
2343
+ throw new APIError("FORBIDDEN", {
2344
+ message: "This account is temporarily locked after too many failed sign-in attempts. Try again later or ask an administrator to unlock it.",
2345
+ code: "ACCOUNT_LOCKED"
2346
+ });
2347
+ }
2348
+ }
2349
+ /**
2350
+ * ADR-0069 D2 — record a sign-in outcome for lockout accounting. On failure
2351
+ * increments `failed_login_count` and, once it reaches `lockoutThreshold`,
2352
+ * stamps `locked_until = now + lockoutDurationMinutes`. On success resets
2353
+ * both (only writing when there is something to clear, to avoid a no-op
2354
+ * history row on every login). No-op when lockout is disabled. Never throws —
2355
+ * a counter write must not turn a valid login into an error.
2356
+ */
2357
+ async recordSignInOutcome(email, success) {
2358
+ const threshold = Number(this.config.lockoutThreshold) || 0;
2359
+ if (threshold <= 0) return;
2360
+ const engine = this.getDataEngine();
2361
+ if (!engine) return;
2362
+ try {
2363
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2364
+ const u = await engine.findOne("sys_user", {
2365
+ where: { email },
2366
+ fields: ["id", "failed_login_count", "locked_until"],
2367
+ context: SYSTEM_CTX
2368
+ });
2369
+ if (!u?.id) return;
2370
+ if (success) {
2371
+ if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) {
2372
+ await engine.update(
2373
+ "sys_user",
2374
+ { id: u.id, failed_login_count: 0, locked_until: null },
2375
+ { context: SYSTEM_CTX }
2376
+ );
2377
+ }
2378
+ return;
2379
+ }
2380
+ const next = (Number(u.failed_login_count) || 0) + 1;
2381
+ const patch = { id: u.id, failed_login_count: next };
2382
+ if (next >= threshold) {
2383
+ const mins = Number(this.config.lockoutDurationMinutes) || 15;
2384
+ patch.locked_until = new Date(Date.now() + mins * 6e4);
2385
+ }
2386
+ await engine.update("sys_user", patch, { context: SYSTEM_CTX });
2387
+ } catch {
2388
+ }
2389
+ }
2390
+ /**
2391
+ * ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
2392
+ * Resets `failed_login_count` and `locked_until`. Returns false when no data
2393
+ * engine is wired or the user does not exist.
2394
+ */
2395
+ async unlockUser(userId) {
2396
+ const engine = this.getDataEngine();
2397
+ if (!engine || !userId) return false;
2398
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2399
+ const u = await engine.findOne("sys_user", {
2400
+ where: { id: userId },
2401
+ fields: ["id"],
2402
+ context: SYSTEM_CTX
2403
+ });
2404
+ if (!u?.id) return false;
2405
+ await engine.update(
2406
+ "sys_user",
2407
+ { id: userId, failed_login_count: 0, locked_until: null },
2408
+ { context: SYSTEM_CTX }
2409
+ );
2410
+ return true;
2411
+ }
2412
+ /**
2413
+ * ADR-0069 D4 — idle / absolute session enforcement, run per request from
2414
+ * `customSession`. No-op when both are off. Revokes (expires in place +
2415
+ * stamps revoked_at/revoke_reason) when a limit is exceeded so better-auth
2416
+ * returns no session on the NEXT request; otherwise touches `last_activity_at`
2417
+ * (throttled to once a minute). Best-effort — never throws.
2418
+ */
2419
+ async enforceSessionControls(sessionId, createdAtHint) {
2420
+ const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0);
2421
+ const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0);
2422
+ if (idleMin <= 0 && absHrs <= 0) return;
2423
+ const engine = this.getDataEngine();
2424
+ if (!engine || !sessionId) return;
2425
+ try {
2426
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2427
+ const srow = await engine.findOne("sys_session", {
2428
+ where: { id: sessionId },
2429
+ fields: ["id", "created_at", "last_activity_at", "revoked_at"],
2430
+ context: SYSTEM_CTX
2431
+ });
2432
+ if (!srow?.id || srow.revoked_at) return;
2433
+ const now = Date.now();
2434
+ let reason;
2435
+ if (absHrs > 0) {
2436
+ const created = srow.created_at ?? createdAtHint;
2437
+ if (created && now - new Date(created).getTime() > absHrs * 36e5) reason = "absolute_max";
2438
+ }
2439
+ if (!reason && idleMin > 0) {
2440
+ const last = srow.last_activity_at ?? srow.created_at ?? createdAtHint;
2441
+ if (last && now - new Date(last).getTime() > idleMin * 6e4) reason = "idle_timeout";
2442
+ }
2443
+ if (reason) {
2444
+ await engine.update(
2445
+ "sys_session",
2446
+ { id: sessionId, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: reason },
2447
+ { context: SYSTEM_CTX }
2448
+ ).catch(() => void 0);
2449
+ return;
2450
+ }
2451
+ if (idleMin > 0) {
2452
+ const la = srow.last_activity_at ? new Date(srow.last_activity_at).getTime() : 0;
2453
+ if (now - la > 6e4) {
2454
+ await engine.update("sys_session", { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX }).catch(() => void 0);
2455
+ }
2456
+ }
2457
+ } catch {
2458
+ }
2459
+ }
2460
+ /**
2461
+ * ADR-0069 D4 — concurrent-session cap, run from the sign-in after-hook.
2462
+ * Keeps the newest `maxConcurrentSessions` live sessions for the user and
2463
+ * revokes the rest (oldest first). No-op when off. Best-effort.
2464
+ */
2465
+ async enforceConcurrentCap(userId) {
2466
+ const cap = Math.floor(Number(this.config.maxConcurrentSessions) || 0);
2467
+ if (cap <= 0 || !userId) return;
2468
+ const engine = this.getDataEngine();
2469
+ if (!engine) return;
2470
+ try {
2471
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2472
+ const rows = await engine.find("sys_session", {
2473
+ where: { user_id: userId },
2474
+ fields: ["id", "created_at", "expires_at", "revoked_at"],
2475
+ limit: 200,
2476
+ context: SYSTEM_CTX
2477
+ });
2478
+ const now = Date.now();
2479
+ const live = (Array.isArray(rows) ? rows : []).filter((sn) => !sn.revoked_at && (!sn.expires_at || new Date(sn.expires_at).getTime() > now)).sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
2480
+ for (const sn of live.slice(cap)) {
2481
+ await engine.update(
2482
+ "sys_session",
2483
+ { id: sn.id, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: "concurrent_cap" },
2484
+ { context: SYSTEM_CTX }
2485
+ ).catch(() => void 0);
2486
+ }
2487
+ } catch {
2488
+ }
2489
+ }
2490
+ /**
2491
+ * ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no
2492
+ * ranges are configured, OR when the IP can't be determined (fail-open so a
2493
+ * misconfigured proxy never locks everyone out — an admin enabling this must
2494
+ * ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6.
2495
+ */
2496
+ isClientIpAllowed(ip) {
2497
+ const ranges = this.config.allowedIpRanges;
2498
+ if (!ranges || ranges.length === 0) return true;
2499
+ if (!ip) return true;
2500
+ return ranges.some((r) => ipMatchesRange(ip, r));
2501
+ }
1390
2502
  /**
1391
2503
  * Returns the data engine wired into this auth manager. Used by route
1392
2504
  * handlers (e.g. bootstrap-status) that need to query identity tables
@@ -1428,6 +2540,283 @@ function mapSetPasswordError(error) {
1428
2540
  return { status, body: { success: false, error: { code, message } } };
1429
2541
  }
1430
2542
 
2543
+ // src/register-sso-provider.ts
2544
+ async function resolveActiveOrganizationId(handle, registerUrl, headers) {
2545
+ try {
2546
+ const sessionUrl = registerUrl.replace(/\/sso\/register$/, "/get-session");
2547
+ if (sessionUrl === registerUrl) return void 0;
2548
+ const h = new Headers({ accept: "application/json" });
2549
+ const cookie = headers.get("cookie");
2550
+ if (cookie) h.set("cookie", cookie);
2551
+ const authz = headers.get("authorization");
2552
+ if (authz) h.set("authorization", authz);
2553
+ const resp = await handle(new Request(sessionUrl, { method: "GET", headers: h }));
2554
+ if (!resp.ok) return void 0;
2555
+ const data = await resp.json().catch(() => null);
2556
+ const org = data?.session?.activeOrganizationId ?? data?.activeOrganizationId;
2557
+ return typeof org === "string" && org.length > 0 ? org : void 0;
2558
+ } catch {
2559
+ return void 0;
2560
+ }
2561
+ }
2562
+ async function runRegisterSsoProviderFromForm(handle, request) {
2563
+ let body;
2564
+ try {
2565
+ body = await request.json();
2566
+ } catch {
2567
+ body = {};
2568
+ }
2569
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2570
+ const providerId = str(body?.providerId);
2571
+ const issuer = str(body?.issuer);
2572
+ const domain = str(body?.domain);
2573
+ const clientId = str(body?.clientId);
2574
+ const clientSecret = str(body?.clientSecret);
2575
+ const discoveryEndpoint = str(body?.discoveryEndpoint);
2576
+ const scopesRaw = str(body?.scopes);
2577
+ const missing = [
2578
+ ["providerId", providerId],
2579
+ ["issuer", issuer],
2580
+ ["domain", domain],
2581
+ ["clientId", clientId],
2582
+ ["clientSecret", clientSecret]
2583
+ ].filter(([, v]) => !v).map(([k]) => k);
2584
+ if (missing.length) {
2585
+ return {
2586
+ status: 400,
2587
+ body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } }
2588
+ };
2589
+ }
2590
+ const oidcConfig = { clientId, clientSecret };
2591
+ if (discoveryEndpoint) oidcConfig.discoveryEndpoint = discoveryEndpoint;
2592
+ oidcConfig.scopes = scopesRaw ? scopesRaw.split(/[\s,]+/).filter(Boolean) : ["openid", "email", "profile"];
2593
+ oidcConfig.mapping = {
2594
+ id: str(body?.mapId) || "sub",
2595
+ email: str(body?.mapEmail) || "email",
2596
+ name: str(body?.mapName) || "name"
2597
+ };
2598
+ let innerUrl;
2599
+ let origin;
2600
+ try {
2601
+ const url = new URL(request.url);
2602
+ origin = url.origin;
2603
+ innerUrl = `${origin}${url.pathname.replace(/\/admin\/sso\/register$/, "/sso/register")}`;
2604
+ } catch {
2605
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2606
+ }
2607
+ const headers = new Headers({ "content-type": "application/json" });
2608
+ const cookie = request.headers.get("cookie");
2609
+ if (cookie) headers.set("cookie", cookie);
2610
+ const authz = request.headers.get("authorization");
2611
+ if (authz) headers.set("authorization", authz);
2612
+ headers.set("origin", request.headers.get("origin") || origin);
2613
+ const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
2614
+ const innerReq = new Request(innerUrl, {
2615
+ method: "POST",
2616
+ headers,
2617
+ body: JSON.stringify({ providerId, issuer, domain, oidcConfig, ...organizationId ? { organizationId } : {} })
2618
+ });
2619
+ const resp = await handle(innerReq);
2620
+ let parsed = {};
2621
+ try {
2622
+ const t = await resp.text();
2623
+ parsed = t ? JSON.parse(t) : {};
2624
+ } catch {
2625
+ parsed = {};
2626
+ }
2627
+ if (!resp.ok) {
2628
+ return {
2629
+ status: resp.status,
2630
+ body: { success: false, error: { code: "sso_register_failed", message: parsed?.message || "SSO provider registration failed" } }
2631
+ };
2632
+ }
2633
+ return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } };
2634
+ }
2635
+ async function runRegisterSamlProviderFromForm(handle, request) {
2636
+ let body;
2637
+ try {
2638
+ body = await request.json();
2639
+ } catch {
2640
+ body = {};
2641
+ }
2642
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2643
+ const providerId = str(body?.providerId);
2644
+ const issuer = str(body?.issuer);
2645
+ const domain = str(body?.domain);
2646
+ const entryPoint = str(body?.entryPoint);
2647
+ const cert = str(body?.cert);
2648
+ const identifierFormat = str(body?.identifierFormat);
2649
+ const missing = [
2650
+ ["providerId", providerId],
2651
+ ["issuer", issuer],
2652
+ ["domain", domain],
2653
+ ["entryPoint", entryPoint],
2654
+ ["cert", cert]
2655
+ ].filter(([, v]) => !v).map(([k]) => k);
2656
+ if (missing.length) {
2657
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } } };
2658
+ }
2659
+ let origin;
2660
+ let prefix;
2661
+ let innerUrl;
2662
+ try {
2663
+ const url = new URL(request.url);
2664
+ origin = url.origin;
2665
+ prefix = url.pathname.replace(/\/admin\/sso\/register-saml$/, "");
2666
+ innerUrl = `${origin}${prefix}/sso/register`;
2667
+ } catch {
2668
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2669
+ }
2670
+ const acsUrl = `${origin}${prefix}/sso/saml2/sp/acs/${encodeURIComponent(providerId)}`;
2671
+ const spMetadataUrl = `${origin}${prefix}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}`;
2672
+ const samlConfig = {
2673
+ entryPoint,
2674
+ cert,
2675
+ callbackUrl: acsUrl,
2676
+ // better-auth requires an SP descriptor (its inner fields are optional). Use
2677
+ // the SP metadata URL as our EntityID — the value the IdP keys this SP on.
2678
+ spMetadata: { entityID: spMetadataUrl }
2679
+ };
2680
+ if (identifierFormat) samlConfig.identifierFormat = identifierFormat;
2681
+ const headers = new Headers({ "content-type": "application/json" });
2682
+ const cookie = request.headers.get("cookie");
2683
+ if (cookie) headers.set("cookie", cookie);
2684
+ const authz = request.headers.get("authorization");
2685
+ if (authz) headers.set("authorization", authz);
2686
+ headers.set("origin", request.headers.get("origin") || origin);
2687
+ const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
2688
+ const innerReq = new Request(innerUrl, {
2689
+ method: "POST",
2690
+ headers,
2691
+ body: JSON.stringify({ providerId, issuer, domain, samlConfig, ...organizationId ? { organizationId } : {} })
2692
+ });
2693
+ const resp = await handle(innerReq);
2694
+ let parsed = {};
2695
+ try {
2696
+ const t = await resp.text();
2697
+ parsed = t ? JSON.parse(t) : {};
2698
+ } catch {
2699
+ parsed = {};
2700
+ }
2701
+ if (!resp.ok) {
2702
+ return { status: resp.status, body: { success: false, error: { code: "saml_register_failed", message: parsed?.message || "SAML provider registration failed" } } };
2703
+ }
2704
+ return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } };
2705
+ }
2706
+ var SSO_DOMAIN_TOKEN_PREFIX = "better-auth-token";
2707
+ function bareHostname(domain) {
2708
+ let d = domain.trim();
2709
+ if (!d) return d;
2710
+ const schemeIdx = d.indexOf("://");
2711
+ if (schemeIdx !== -1) {
2712
+ try {
2713
+ return new URL(d).hostname;
2714
+ } catch {
2715
+ d = d.slice(schemeIdx + 3);
2716
+ }
2717
+ }
2718
+ for (const sep of ["/", ":", "?", "#"]) {
2719
+ const i = d.indexOf(sep);
2720
+ if (i !== -1) d = d.slice(0, i);
2721
+ }
2722
+ return d;
2723
+ }
2724
+ function rewriteSsoAdminUrl(request, fromSuffix, toPath) {
2725
+ try {
2726
+ const url = new URL(request.url);
2727
+ return { origin: url.origin, innerUrl: `${url.origin}${url.pathname.replace(fromSuffix, toPath)}` };
2728
+ } catch {
2729
+ return null;
2730
+ }
2731
+ }
2732
+ function forwardAuthHeaders(request, origin) {
2733
+ const headers = new Headers({ "content-type": "application/json" });
2734
+ const cookie = request.headers.get("cookie");
2735
+ if (cookie) headers.set("cookie", cookie);
2736
+ const authz = request.headers.get("authorization");
2737
+ if (authz) headers.set("authorization", authz);
2738
+ headers.set("origin", request.headers.get("origin") || origin);
2739
+ return headers;
2740
+ }
2741
+ async function runRequestDomainVerification(handle, request) {
2742
+ let body;
2743
+ try {
2744
+ body = await request.json();
2745
+ } catch {
2746
+ body = {};
2747
+ }
2748
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2749
+ const providerId = str(body?.providerId);
2750
+ const domain = bareHostname(str(body?.domain));
2751
+ if (!providerId) {
2752
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
2753
+ }
2754
+ const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, "/sso/request-domain-verification");
2755
+ if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2756
+ const headers = forwardAuthHeaders(request, rw.origin);
2757
+ const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
2758
+ let parsed = {};
2759
+ try {
2760
+ const t = await resp.text();
2761
+ parsed = t ? JSON.parse(t) : {};
2762
+ } catch {
2763
+ parsed = {};
2764
+ }
2765
+ if (!resp.ok) {
2766
+ if (resp.status === 404 && !parsed?.code) {
2767
+ return { status: 400, body: { success: false, error: { code: "domain_verification_disabled", message: "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION)." } } };
2768
+ }
2769
+ return { status: resp.status, body: { success: false, error: { code: parsed?.code || "request_domain_verification_failed", message: parsed?.message || "Failed to request domain verification" } } };
2770
+ }
2771
+ const token = str(parsed?.domainVerificationToken);
2772
+ const label = `_${SSO_DOMAIN_TOKEN_PREFIX}-${providerId}`;
2773
+ const dnsRecordName = domain ? `${label}.${domain}` : label;
2774
+ const dnsRecordValue = `${label}=${token}`;
2775
+ return {
2776
+ status: 200,
2777
+ body: {
2778
+ success: true,
2779
+ data: { providerId, domain, token, dnsRecordType: "TXT", dnsRecordName, dnsRecordValue }
2780
+ }
2781
+ };
2782
+ }
2783
+ async function runVerifyDomain(handle, request) {
2784
+ let body;
2785
+ try {
2786
+ body = await request.json();
2787
+ } catch {
2788
+ body = {};
2789
+ }
2790
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2791
+ const providerId = str(body?.providerId);
2792
+ if (!providerId) {
2793
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
2794
+ }
2795
+ const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, "/sso/verify-domain");
2796
+ if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2797
+ const headers = forwardAuthHeaders(request, rw.origin);
2798
+ const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
2799
+ let parsed = {};
2800
+ try {
2801
+ const t = await resp.text();
2802
+ parsed = t ? JSON.parse(t) : {};
2803
+ } catch {
2804
+ parsed = {};
2805
+ }
2806
+ if (resp.ok) {
2807
+ return { status: 200, body: { success: true, data: { providerId, verified: true, message: "Domain ownership verified \u2014 this provider can now sign users in." } } };
2808
+ }
2809
+ let message = parsed?.message || "Domain verification failed";
2810
+ if (resp.status === 404 && !parsed?.code) {
2811
+ message = "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).";
2812
+ } else if (parsed?.code === "NO_PENDING_VERIFICATION") {
2813
+ message = "No pending verification \u2014 click \u201CRequest Domain Verification\u201D first to get the DNS record.";
2814
+ } else if (parsed?.code === "DOMAIN_VERIFICATION_FAILED") {
2815
+ message = "DNS TXT record not found yet. Add the record shown when you requested verification, allow time for DNS to propagate, then retry.";
2816
+ }
2817
+ return { status: resp.status, body: { success: false, error: { code: parsed?.code || "verify_domain_failed", message } } };
2818
+ }
2819
+
1431
2820
  // src/manifest.ts
1432
2821
  import {
1433
2822
  SysAccount,
@@ -1442,6 +2831,8 @@ import {
1442
2831
  SysOauthRefreshToken,
1443
2832
  SysOrganization,
1444
2833
  SysSession,
2834
+ SysSsoProvider,
2835
+ SysScimProvider,
1445
2836
  SysTeam,
1446
2837
  SysTeamMember,
1447
2838
  SysTwoFactor,
@@ -1469,7 +2860,9 @@ var authIdentityObjects = [
1469
2860
  SysOauthRefreshToken,
1470
2861
  SysOauthConsent,
1471
2862
  SysJwks,
1472
- SysDeviceCode
2863
+ SysDeviceCode,
2864
+ SysSsoProvider,
2865
+ SysScimProvider
1473
2866
  ];
1474
2867
  var authPluginManifestHeader = {
1475
2868
  id: AUTH_PLUGIN_ID,
@@ -1562,7 +2955,33 @@ var AuthPlugin = class {
1562
2955
  // source of truth.
1563
2956
  dashboards: [SystemOverviewDashboard],
1564
2957
  // ADR-0021 — datasets backing the System Overview dashboard's widgets.
1565
- datasets: SystemOverviewDatasets
2958
+ datasets: SystemOverviewDatasets,
2959
+ // ADR-0024 / cloud#551 — surface "SSO Providers" (sys_sso_provider) in the
2960
+ // Setup app's Access Control group, but ONLY when the external-IdP RP is
2961
+ // wired (self-host `OS_SSO_ENABLED`, or the cloud per-env `planAllowsSso`
2962
+ // arriving via `plugins.sso`). Without the gate the entry would render an
2963
+ // empty list + a "Register" button whose endpoint 404s when SSO is off.
2964
+ // Owning-plugin-contributes pattern (ADR-0029 K2), mirroring plugin-security.
2965
+ ...this.authManager.isSsoWired() ? {
2966
+ navigationContributions: [
2967
+ {
2968
+ app: "setup",
2969
+ group: "group_access_control",
2970
+ // After Roles/Permission-Sets (100) and Sharing (200), near API Keys (300).
2971
+ priority: 250,
2972
+ items: [
2973
+ {
2974
+ id: "nav_sso_providers",
2975
+ type: "object",
2976
+ label: "SSO Providers",
2977
+ objectName: "sys_sso_provider",
2978
+ icon: "log-in",
2979
+ requiredPermissions: ["manage_platform_settings"]
2980
+ }
2981
+ ]
2982
+ }
2983
+ ]
2984
+ } : {}
1566
2985
  });
1567
2986
  ctx.logger.info("Auth Plugin initialized successfully");
1568
2987
  }
@@ -1575,14 +2994,24 @@ var AuthPlugin = class {
1575
2994
  ctx.hook("kernel:ready", async () => {
1576
2995
  if (this.authManager) {
1577
2996
  await this.bindAuthSettings(ctx);
2997
+ let emailSvc;
1578
2998
  try {
1579
- const emailSvc = ctx.getService("email");
1580
- if (emailSvc) {
1581
- this.authManager.setEmailService(emailSvc);
1582
- ctx.logger.info("Auth: email service wired (transactional mail enabled)");
1583
- }
2999
+ emailSvc = ctx.getService("email");
1584
3000
  } catch {
1585
- ctx.logger.info("Auth: no email service registered \u2014 auth callbacks will log instead of sending");
3001
+ emailSvc = void 0;
3002
+ }
3003
+ if (emailSvc) {
3004
+ this.authManager.setEmailService(emailSvc);
3005
+ ctx.logger.info("Auth: email service wired (transactional mail enabled)");
3006
+ } else {
3007
+ const requiresEmail = !!this.authManager.getPublicConfig?.()?.emailPassword?.requireEmailVerification;
3008
+ if (requiresEmail) {
3009
+ ctx.logger.error(
3010
+ "Auth: email verification is REQUIRED but NO email service is registered \u2014 verification & password-reset emails will FAIL and new users will be locked out at sign-in. Register an email service (e.g. EmailServicePlugin + OS_EMAIL_*) or disable verification (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false)."
3011
+ );
3012
+ } else {
3013
+ ctx.logger.info("Auth: no email service registered \u2014 transactional mail disabled");
3014
+ }
1586
3015
  }
1587
3016
  try {
1588
3017
  const settings = ctx.getService("settings");
@@ -1645,6 +3074,38 @@ var AuthPlugin = class {
1645
3074
  ctx.hook("kernel:ready", async () => {
1646
3075
  await this.maybeSeedDevAdmin(ctx);
1647
3076
  });
3077
+ ctx.hook("kernel:ready", async () => {
3078
+ try {
3079
+ const engine = ctx.getService("objectql");
3080
+ if (!engine || typeof engine.registerHook !== "function") return;
3081
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
3082
+ engine.registerHook("afterInsert", async (hookCtx) => {
3083
+ try {
3084
+ if (hookCtx?.object !== "sys_account") return;
3085
+ const acct = hookCtx.result ?? {};
3086
+ const providerId = acct.provider_id ?? acct.providerId;
3087
+ const userId = acct.user_id ?? acct.userId;
3088
+ if (!userId || !providerId || providerId === "credential") return;
3089
+ const credCount = await engine.count("sys_account", {
3090
+ where: { user_id: userId, provider_id: "credential" },
3091
+ context: SYSTEM_CTX
3092
+ });
3093
+ if (typeof credCount === "number" && credCount > 0) return;
3094
+ const u = await engine.findOne("sys_user", {
3095
+ where: { id: userId },
3096
+ fields: ["id", "source"],
3097
+ context: SYSTEM_CTX
3098
+ });
3099
+ if (u && u.source !== "idp_provisioned") {
3100
+ await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
3101
+ }
3102
+ } catch {
3103
+ }
3104
+ }, { packageId: "com.objectstack.plugin-auth" });
3105
+ ctx.logger.info("Identity-source afterInsert stamp registered on sys_account (SCIM-safe)");
3106
+ } catch {
3107
+ }
3108
+ });
1648
3109
  try {
1649
3110
  const ql = ctx.getService("objectql");
1650
3111
  if (ql && typeof ql.registerMiddleware === "function") {
@@ -1728,6 +3189,41 @@ var AuthPlugin = class {
1728
3189
  if (Object.keys(emailAndPassword).length > 0) {
1729
3190
  patch.emailAndPassword = emailAndPassword;
1730
3191
  }
3192
+ if (isExplicit("password_reject_breached")) {
3193
+ patch.plugins = {
3194
+ ...patch.plugins ?? {},
3195
+ passwordRejectBreached: asBoolean(values.password_reject_breached, false)
3196
+ };
3197
+ }
3198
+ if (isExplicit("password_require_complexity")) {
3199
+ patch.passwordRequireComplexity = asBoolean(values.password_require_complexity, false);
3200
+ }
3201
+ if (isExplicit("password_min_classes")) {
3202
+ const n = asPositiveInt(values.password_min_classes);
3203
+ if (n !== void 0) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
3204
+ }
3205
+ if (isExplicit("password_history_count")) {
3206
+ const n = Math.floor(Number(values.password_history_count));
3207
+ if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
3208
+ }
3209
+ if (isExplicit("password_expiry_days")) {
3210
+ const n = Math.floor(Number(values.password_expiry_days));
3211
+ if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n);
3212
+ }
3213
+ if (isExplicit("mfa_required")) {
3214
+ const on = asBoolean(values.mfa_required, false);
3215
+ patch.mfaRequired = on;
3216
+ if (on) {
3217
+ patch.plugins = {
3218
+ ...patch.plugins ?? {},
3219
+ twoFactor: true
3220
+ };
3221
+ }
3222
+ }
3223
+ if (isExplicit("mfa_grace_period_days")) {
3224
+ const n = Math.floor(Number(values.mfa_grace_period_days));
3225
+ if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n);
3226
+ }
1731
3227
  const session = {};
1732
3228
  if (isExplicit("session_expiry_days")) {
1733
3229
  const d = asPositiveInt(values.session_expiry_days);
@@ -1740,6 +3236,53 @@ var AuthPlugin = class {
1740
3236
  if (Object.keys(session).length > 0) {
1741
3237
  patch.session = session;
1742
3238
  }
3239
+ const asNonNeg = (v) => {
3240
+ const n = Math.floor(Number(v));
3241
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
3242
+ };
3243
+ if (isExplicit("session_idle_timeout_minutes")) {
3244
+ const n = asNonNeg(values.session_idle_timeout_minutes);
3245
+ if (n !== void 0) patch.sessionIdleTimeoutMinutes = n;
3246
+ }
3247
+ if (isExplicit("session_absolute_max_hours")) {
3248
+ const n = asNonNeg(values.session_absolute_max_hours);
3249
+ if (n !== void 0) patch.sessionAbsoluteMaxHours = n;
3250
+ }
3251
+ if (isExplicit("max_concurrent_sessions_per_user")) {
3252
+ const n = asNonNeg(values.max_concurrent_sessions_per_user);
3253
+ if (n !== void 0) patch.maxConcurrentSessions = n;
3254
+ }
3255
+ if (isExplicit("allowed_ip_ranges")) {
3256
+ const raw = asTrimmedString(values.allowed_ip_ranges) ?? "";
3257
+ patch.allowedIpRanges = raw.split(/[\n,]+/).map((r) => r.trim()).filter(Boolean);
3258
+ }
3259
+ const asNonNegativeInt = (value) => {
3260
+ const n = Math.floor(Number(value));
3261
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
3262
+ };
3263
+ if (isExplicit("lockout_threshold")) {
3264
+ const n = asNonNegativeInt(values.lockout_threshold);
3265
+ if (n !== void 0) patch.lockoutThreshold = n;
3266
+ }
3267
+ if (isExplicit("lockout_duration_minutes")) {
3268
+ const n = asPositiveInt(values.lockout_duration_minutes);
3269
+ if (n !== void 0) patch.lockoutDurationMinutes = n;
3270
+ }
3271
+ if (isExplicit("rate_limit_max") || isExplicit("rate_limit_window_seconds")) {
3272
+ const max = asPositiveInt(values.rate_limit_max) ?? 10;
3273
+ const window = asPositiveInt(values.rate_limit_window_seconds) ?? 60;
3274
+ patch.rateLimit = {
3275
+ enabled: true,
3276
+ window,
3277
+ max,
3278
+ customRules: {
3279
+ "/sign-in/email": { window, max },
3280
+ "/sign-up/email": { window, max },
3281
+ "/request-password-reset": { window, max },
3282
+ "/reset-password": { window, max }
3283
+ }
3284
+ };
3285
+ }
1743
3286
  if (isExplicit("google_enabled") || isExplicit("google_client_id") || isExplicit("google_client_secret")) {
1744
3287
  const socialProviders = {
1745
3288
  ...this.configuredSocialProviders ?? {}
@@ -1861,9 +3404,27 @@ var AuthPlugin = class {
1861
3404
  );
1862
3405
  }
1863
3406
  const rawApp = httpServer.getRawApp();
1864
- rawApp.get(`${basePath}/config`, (c) => {
3407
+ if (typeof rawApp.use === "function") rawApp.use(`${basePath}/*`, async (c, next) => {
3408
+ const mgr = this.authManager;
3409
+ if (!mgr || typeof mgr.isClientIpAllowed !== "function") return next();
3410
+ const path = c.req.path || "";
3411
+ if (path.endsWith("/config") || path.endsWith("/bootstrap-status")) return next();
3412
+ const fwd = c.req.header("x-forwarded-for");
3413
+ const ip = typeof fwd === "string" && fwd.split(",")[0].trim() || c.req.header("cf-connecting-ip") || c.req.header("x-real-ip") || void 0;
3414
+ if (!mgr.isClientIpAllowed(ip)) {
3415
+ return c.json(
3416
+ { success: false, error: { code: "IP_NOT_ALLOWED", message: "Sign-in is not allowed from your network." } },
3417
+ 403
3418
+ );
3419
+ }
3420
+ return next();
3421
+ });
3422
+ rawApp.get(`${basePath}/config`, async (c) => {
1865
3423
  try {
1866
3424
  const config = this.authManager.getPublicConfig();
3425
+ if (config.features?.sso) {
3426
+ config.features.sso = await this.authManager.isSsoUsable();
3427
+ }
1867
3428
  return c.json({ success: true, data: config });
1868
3429
  } catch (error) {
1869
3430
  const err = error instanceof Error ? error : new Error(String(error));
@@ -1949,6 +3510,91 @@ var AuthPlugin = class {
1949
3510
  return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
1950
3511
  }
1951
3512
  });
3513
+ rawApp.post(`${basePath}/admin/sso/register`, async (c) => {
3514
+ try {
3515
+ const { status, body } = await runRegisterSsoProviderFromForm(
3516
+ (req) => this.authManager.handleRequest(req),
3517
+ c.req.raw
3518
+ );
3519
+ return c.json(body, status);
3520
+ } catch (error) {
3521
+ const err = error instanceof Error ? error : new Error(String(error));
3522
+ ctx.logger.error("[AuthPlugin] sso/register bridge failed", err);
3523
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3524
+ }
3525
+ });
3526
+ rawApp.post(`${basePath}/admin/unlock-user`, async (c) => {
3527
+ try {
3528
+ let body = {};
3529
+ try {
3530
+ body = await c.req.json();
3531
+ } catch {
3532
+ body = {};
3533
+ }
3534
+ const userId = body?.userId ?? body?.user_id;
3535
+ if (typeof userId !== "string" || userId.length === 0) {
3536
+ return c.json({ success: false, error: { code: "invalid_request", message: "userId is required" } }, 400);
3537
+ }
3538
+ const authApi = await this.authManager.getApi();
3539
+ const session = await authApi.getSession({ headers: c.req.raw.headers });
3540
+ if (!session?.user?.id) {
3541
+ return c.json({ success: false, error: { code: "unauthorized", message: "Sign in first" } }, 401);
3542
+ }
3543
+ const u = session.user;
3544
+ const isAdmin = u?.isPlatformAdmin === true || Array.isArray(u?.roles) && u.roles.includes("platform_admin") || u?.role === "admin";
3545
+ if (!isAdmin) {
3546
+ return c.json({ success: false, error: { code: "forbidden", message: "Admin role required" } }, 403);
3547
+ }
3548
+ const ok = await this.authManager.unlockUser(userId);
3549
+ if (!ok) {
3550
+ return c.json({ success: false, error: { code: "not_found", message: "User not found or data engine unavailable" } }, 404);
3551
+ }
3552
+ return c.json({ success: true, data: { userId } });
3553
+ } catch (error) {
3554
+ const err = error instanceof Error ? error : new Error(String(error));
3555
+ ctx.logger.error("[AuthPlugin] unlock-user failed", err);
3556
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3557
+ }
3558
+ });
3559
+ rawApp.post(`${basePath}/admin/sso/register-saml`, async (c) => {
3560
+ try {
3561
+ const { status, body } = await runRegisterSamlProviderFromForm(
3562
+ (req) => this.authManager.handleRequest(req),
3563
+ c.req.raw
3564
+ );
3565
+ return c.json(body, status);
3566
+ } catch (error) {
3567
+ const err = error instanceof Error ? error : new Error(String(error));
3568
+ ctx.logger.error("[AuthPlugin] sso/register-saml bridge failed", err);
3569
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3570
+ }
3571
+ });
3572
+ rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c) => {
3573
+ try {
3574
+ const { status, body } = await runRequestDomainVerification(
3575
+ (req) => this.authManager.handleRequest(req),
3576
+ c.req.raw
3577
+ );
3578
+ return c.json(body, status);
3579
+ } catch (error) {
3580
+ const err = error instanceof Error ? error : new Error(String(error));
3581
+ ctx.logger.error("[AuthPlugin] sso/request-domain-verification bridge failed", err);
3582
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3583
+ }
3584
+ });
3585
+ rawApp.post(`${basePath}/admin/sso/verify-domain`, async (c) => {
3586
+ try {
3587
+ const { status, body } = await runVerifyDomain(
3588
+ (req) => this.authManager.handleRequest(req),
3589
+ c.req.raw
3590
+ );
3591
+ return c.json(body, status);
3592
+ } catch (error) {
3593
+ const err = error instanceof Error ? error : new Error(String(error));
3594
+ ctx.logger.error("[AuthPlugin] sso/verify-domain bridge failed", err);
3595
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3596
+ }
3597
+ });
1952
3598
  rawApp.post(`${basePath}/sys-oauth-application/register`, async (c) => {
1953
3599
  try {
1954
3600
  let body = {};
@@ -2094,7 +3740,9 @@ export {
2094
3740
  AUTH_OAUTH_REFRESH_TOKEN_SCHEMA,
2095
3741
  AUTH_ORGANIZATION_SCHEMA,
2096
3742
  AUTH_ORG_SESSION_FIELDS,
3743
+ AUTH_SCIM_PROVIDER_SCHEMA,
2097
3744
  AUTH_SESSION_CONFIG,
3745
+ AUTH_SSO_PROVIDER_SCHEMA,
2098
3746
  AUTH_TEAM_MEMBER_SCHEMA,
2099
3747
  AUTH_TEAM_SCHEMA,
2100
3748
  AUTH_TWO_FACTOR_SCHEMA,
@@ -2112,7 +3760,13 @@ export {
2112
3760
  buildTwoFactorPluginSchema,
2113
3761
  createObjectQLAdapter,
2114
3762
  createObjectQLAdapterFactory,
3763
+ ipMatchesRange,
2115
3764
  resolveProtocolName,
2116
- runSetInitialPassword
3765
+ runRegisterSamlProviderFromForm,
3766
+ runRegisterSsoProviderFromForm,
3767
+ runRequestDomainVerification,
3768
+ runSetInitialPassword,
3769
+ runVerifyDomain,
3770
+ withSystemReadContext
2117
3771
  };
2118
3772
  //# sourceMappingURL=index.mjs.map