@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.js CHANGED
@@ -45,7 +45,9 @@ __export(index_exports, {
45
45
  AUTH_OAUTH_REFRESH_TOKEN_SCHEMA: () => AUTH_OAUTH_REFRESH_TOKEN_SCHEMA,
46
46
  AUTH_ORGANIZATION_SCHEMA: () => AUTH_ORGANIZATION_SCHEMA,
47
47
  AUTH_ORG_SESSION_FIELDS: () => AUTH_ORG_SESSION_FIELDS,
48
+ AUTH_SCIM_PROVIDER_SCHEMA: () => AUTH_SCIM_PROVIDER_SCHEMA,
48
49
  AUTH_SESSION_CONFIG: () => AUTH_SESSION_CONFIG,
50
+ AUTH_SSO_PROVIDER_SCHEMA: () => AUTH_SSO_PROVIDER_SCHEMA,
49
51
  AUTH_TEAM_MEMBER_SCHEMA: () => AUTH_TEAM_MEMBER_SCHEMA,
50
52
  AUTH_TEAM_SCHEMA: () => AUTH_TEAM_SCHEMA,
51
53
  AUTH_TWO_FACTOR_SCHEMA: () => AUTH_TWO_FACTOR_SCHEMA,
@@ -63,8 +65,14 @@ __export(index_exports, {
63
65
  buildTwoFactorPluginSchema: () => buildTwoFactorPluginSchema,
64
66
  createObjectQLAdapter: () => createObjectQLAdapter,
65
67
  createObjectQLAdapterFactory: () => createObjectQLAdapterFactory,
68
+ ipMatchesRange: () => ipMatchesRange,
66
69
  resolveProtocolName: () => resolveProtocolName,
67
- runSetInitialPassword: () => runSetInitialPassword
70
+ runRegisterSamlProviderFromForm: () => runRegisterSamlProviderFromForm,
71
+ runRegisterSsoProviderFromForm: () => runRegisterSsoProviderFromForm,
72
+ runRequestDomainVerification: () => runRequestDomainVerification,
73
+ runSetInitialPassword: () => runSetInitialPassword,
74
+ runVerifyDomain: () => runVerifyDomain,
75
+ withSystemReadContext: () => withSystemReadContext
68
76
  });
69
77
  module.exports = __toCommonJS(index_exports);
70
78
 
@@ -75,6 +83,7 @@ var import_pages = require("@objectstack/platform-objects/pages");
75
83
 
76
84
  // src/auth-manager.ts
77
85
  var import_types = require("@objectstack/types");
86
+ var import_spec = require("@objectstack/spec");
78
87
 
79
88
  // src/objectql-adapter.ts
80
89
  var import_adapters = require("better-auth/adapters");
@@ -83,7 +92,16 @@ var AUTH_MODEL_TO_PROTOCOL = {
83
92
  user: import_system.SystemObjectName.USER,
84
93
  session: import_system.SystemObjectName.SESSION,
85
94
  account: import_system.SystemObjectName.ACCOUNT,
86
- verification: import_system.SystemObjectName.VERIFICATION
95
+ verification: import_system.SystemObjectName.VERIFICATION,
96
+ // Plugin models. `@better-auth/sso` and `@better-auth/scim` both hardcode
97
+ // their model name and accept NO `schema` option (verified vs 1.6.2x — no
98
+ // mergeSchema, runtime never reads options.schema), so the table name is
99
+ // bridged here and `createObjectQLAdapterFactory` (below) auto-maps their
100
+ // camelCase fields to snake_case (oidcConfig→oidc_config, scimToken→
101
+ // scim_token, …) on every CRUD op via resolveProtocolName. Off by default
102
+ // (OS_SSO_ENABLED / OS_SCIM_ENABLED). See ADR-0024 / ADR-0071.
103
+ ssoProvider: "sys_sso_provider",
104
+ scimProvider: "sys_scim_provider"
87
105
  };
88
106
  function resolveProtocolName(model) {
89
107
  return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
@@ -147,7 +165,28 @@ function convertWhere(where) {
147
165
  }
148
166
  return filter;
149
167
  }
150
- function createObjectQLAdapterFactory(dataEngine) {
168
+ function withSystemReadContext(engine) {
169
+ const e = engine;
170
+ const asSystem = (q) => ({ ...q ?? {}, context: { isSystem: true, ...q?.context ?? {} } });
171
+ return {
172
+ insert: (m, d) => e.insert(m, d),
173
+ update: (m, d) => e.update(m, d),
174
+ delete: (m, q) => e.delete(m, q),
175
+ find: (m, q) => e.find(m, asSystem(q)),
176
+ findOne: (m, q) => e.findOne(m, asSystem(q)),
177
+ count: (m, q) => e.count(m, asSystem(q))
178
+ };
179
+ }
180
+ function createObjectQLAdapterFactory(rawDataEngine) {
181
+ const dataEngine = withSystemReadContext(rawDataEngine);
182
+ const camelToSnake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
183
+ const snakeToCamel = (s) => s.replace(/_([a-z])/g, (_m, c) => c.toUpperCase());
184
+ const remapKeys = (obj, fn) => {
185
+ const out = {};
186
+ for (const k of Object.keys(obj)) out[fn(k)] = obj[k];
187
+ return out;
188
+ };
189
+ const remapWhere = (where) => where.map((c) => ({ ...c, field: camelToSnake(c.field) }));
151
190
  return (0, import_adapters.createAdapterFactory)({
152
191
  config: {
153
192
  adapterId: "objectql",
@@ -162,62 +201,90 @@ function createObjectQLAdapterFactory(dataEngine) {
162
201
  },
163
202
  adapter: () => ({
164
203
  create: async ({ model, data, select: _select }) => {
165
- const result = await dataEngine.insert(model, data);
166
- return normaliseLegacyDates(model, result);
204
+ const objectName = resolveProtocolName(model);
205
+ const bridged = objectName !== model;
206
+ const result = await dataEngine.insert(objectName, bridged ? remapKeys(data, camelToSnake) : data);
207
+ const norm = normaliseLegacyDates(model, result);
208
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
167
209
  },
168
210
  findOne: async ({ model, where, select, join: _join }) => {
169
- const filter = convertWhere(where);
170
- const result = await dataEngine.findOne(model, { where: filter, fields: select });
171
- return result ? normaliseLegacyDates(model, result) : null;
211
+ const objectName = resolveProtocolName(model);
212
+ const bridged = objectName !== model;
213
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
214
+ const fields = bridged && select ? select.map(camelToSnake) : select;
215
+ const result = await dataEngine.findOne(objectName, { where: filter, fields });
216
+ if (!result) return null;
217
+ const norm = normaliseLegacyDates(model, result);
218
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
172
219
  },
173
220
  findMany: async ({ model, where, limit, offset, sortBy, join: _join }) => {
174
- const filter = where ? convertWhere(where) : {};
175
- const orderBy = sortBy ? [{ field: sortBy.field, order: sortBy.direction }] : void 0;
176
- const results = await dataEngine.find(model, {
221
+ const objectName = resolveProtocolName(model);
222
+ const bridged = objectName !== model;
223
+ const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
224
+ const orderBy = sortBy ? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction }] : void 0;
225
+ const results = await dataEngine.find(objectName, {
177
226
  where: filter,
178
227
  limit: limit || 100,
179
228
  offset,
180
229
  orderBy
181
230
  });
182
- return results.map((r) => normaliseLegacyDates(model, r));
231
+ return results.map((r) => {
232
+ const norm = normaliseLegacyDates(model, r);
233
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
234
+ });
183
235
  },
184
236
  count: async ({ model, where }) => {
185
- const filter = where ? convertWhere(where) : {};
186
- return await dataEngine.count(model, { where: filter });
237
+ const objectName = resolveProtocolName(model);
238
+ const bridged = objectName !== model;
239
+ const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
240
+ return await dataEngine.count(objectName, { where: filter });
187
241
  },
188
242
  update: async ({ model, where, update }) => {
189
- const filter = convertWhere(where);
190
- const record = await dataEngine.findOne(model, { where: filter });
243
+ const objectName = resolveProtocolName(model);
244
+ const bridged = objectName !== model;
245
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
246
+ const record = await dataEngine.findOne(objectName, { where: filter });
191
247
  if (!record) return null;
192
- const result = await dataEngine.update(model, { ...update, id: record.id });
193
- return result ? normaliseLegacyDates(model, result) : null;
248
+ const patch = bridged ? remapKeys(update, camelToSnake) : update;
249
+ const result = await dataEngine.update(objectName, { ...patch, id: record.id });
250
+ if (!result) return null;
251
+ const norm = normaliseLegacyDates(model, result);
252
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
194
253
  },
195
254
  updateMany: async ({ model, where, update }) => {
196
- const filter = convertWhere(where);
197
- const records = await dataEngine.find(model, { where: filter });
255
+ const objectName = resolveProtocolName(model);
256
+ const bridged = objectName !== model;
257
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
258
+ const records = await dataEngine.find(objectName, { where: filter });
259
+ const patch = bridged ? remapKeys(update, camelToSnake) : update;
198
260
  for (const record of records) {
199
- await dataEngine.update(model, { ...update, id: record.id });
261
+ await dataEngine.update(objectName, { ...patch, id: record.id });
200
262
  }
201
263
  return records.length;
202
264
  },
203
265
  delete: async ({ model, where }) => {
204
- const filter = convertWhere(where);
205
- const record = await dataEngine.findOne(model, { where: filter });
266
+ const objectName = resolveProtocolName(model);
267
+ const bridged = objectName !== model;
268
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
269
+ const record = await dataEngine.findOne(objectName, { where: filter });
206
270
  if (!record) return;
207
- await dataEngine.delete(model, { where: { id: record.id } });
271
+ await dataEngine.delete(objectName, { where: { id: record.id } });
208
272
  },
209
273
  deleteMany: async ({ model, where }) => {
210
- const filter = convertWhere(where);
211
- const records = await dataEngine.find(model, { where: filter });
274
+ const objectName = resolveProtocolName(model);
275
+ const bridged = objectName !== model;
276
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
277
+ const records = await dataEngine.find(objectName, { where: filter });
212
278
  for (const record of records) {
213
- await dataEngine.delete(model, { where: { id: record.id } });
279
+ await dataEngine.delete(objectName, { where: { id: record.id } });
214
280
  }
215
281
  return records.length;
216
282
  }
217
283
  })
218
284
  });
219
285
  }
220
- function createObjectQLAdapter(dataEngine) {
286
+ function createObjectQLAdapter(rawDataEngine) {
287
+ const dataEngine = withSystemReadContext(rawDataEngine);
221
288
  return {
222
289
  create: async ({ model, data, select: _select }) => {
223
290
  const objectName = resolveProtocolName(model);
@@ -523,6 +590,31 @@ function buildOauthProviderPluginSchema() {
523
590
  };
524
591
  }
525
592
  var buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
593
+ var AUTH_SSO_PROVIDER_SCHEMA = {
594
+ modelName: "sys_sso_provider",
595
+ fields: {
596
+ providerId: "provider_id",
597
+ oidcConfig: "oidc_config",
598
+ samlConfig: "saml_config",
599
+ userId: "user_id",
600
+ organizationId: "organization_id",
601
+ // DNS domain-ownership proof (ADR-0024 ②). @better-auth/sso writes
602
+ // `domainVerified` on its `ssoProvider` model when domain verification is
603
+ // enabled; map it so the env can surface a verified/unverified badge. The
604
+ // one-time `domainVerificationToken` is NOT a provider column — it lives in
605
+ // the verification table and is returned only from request-domain-verification.
606
+ domainVerified: "domain_verified"
607
+ }
608
+ };
609
+ var AUTH_SCIM_PROVIDER_SCHEMA = {
610
+ modelName: "sys_scim_provider",
611
+ fields: {
612
+ providerId: "provider_id",
613
+ scimToken: "scim_token",
614
+ organizationId: "organization_id",
615
+ userId: "user_id"
616
+ }
617
+ };
526
618
  function buildDeviceAuthorizationPluginSchema() {
527
619
  return {
528
620
  deviceCode: AUTH_DEVICE_CODE_SCHEMA
@@ -587,9 +679,39 @@ function readDisableSignUpEnv() {
587
679
  if (signupEnabled != null) return !signupEnabled;
588
680
  return readBooleanEnv("OS_DISABLE_SIGNUP");
589
681
  }
682
+ function readSsoOnlyEnv() {
683
+ return readBooleanEnv("OS_AUTH_SSO_ONLY");
684
+ }
685
+ function ipv4ToInt(ip) {
686
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim());
687
+ if (!m) return null;
688
+ const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
689
+ if (p.some((n) => n > 255)) return null;
690
+ return (p[0] << 24 >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3] >>> 0;
691
+ }
692
+ function ipMatchesRange(ip, range) {
693
+ const r = (range || "").trim();
694
+ if (!r) return false;
695
+ if (r.includes("/")) {
696
+ const [base, bitsStr] = r.split("/");
697
+ const bits = Number(bitsStr);
698
+ const ipInt = ipv4ToInt(ip);
699
+ const baseInt = ipv4ToInt(base);
700
+ if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) {
701
+ return ip.trim() === base.trim();
702
+ }
703
+ const mask = bits === 0 ? 0 : ~0 << 32 - bits >>> 0;
704
+ return (ipInt & mask) >>> 0 === (baseInt & mask) >>> 0;
705
+ }
706
+ return ip.trim() === r;
707
+ }
590
708
  var AuthManager = class {
591
709
  constructor(config) {
592
710
  this.auth = null;
711
+ // ADR-0069 — cached "does any org require MFA" flag (per-org tightening).
712
+ // Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap.
713
+ this._orgMfaCache = { value: false, at: 0 };
714
+ this._orgMfaRefreshing = false;
593
715
  this.config = config;
594
716
  installWebContainerRequestStatePolyfill();
595
717
  if (config.authInstance) {
@@ -626,6 +748,14 @@ var AuthManager = class {
626
748
  // createAdapterFactory.
627
749
  user: {
628
750
  ...AUTH_USER_CONFIG
751
+ // NOTE: the env-side AI-seat marker `sys_user.ai_access` is deliberately
752
+ // NOT declared as a better-auth additionalField. sys_user is a
753
+ // better-auth-MANAGED table and better-auth SELECTs explicit columns, so
754
+ // declaring it here would make getSession query a column that may not
755
+ // exist on every env yet → broken auth. Instead the column is owned by
756
+ // the objectql `SysUser` object def (provisioned by boot schema-sync)
757
+ // and read by a GUARDED system query in resolveCtx (can only no-op,
758
+ // never break auth). better-auth stays oblivious to the extra column.
629
759
  },
630
760
  account: {
631
761
  ...AUTH_ACCOUNT_CONFIG,
@@ -674,7 +804,7 @@ var AuthManager = class {
674
804
  // lock the registration policy without relying on UI state.
675
805
  emailAndPassword: (() => {
676
806
  const disableSignUpFromEnv = readDisableSignUpEnv();
677
- const effectiveDisableSignUp = disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
807
+ const effectiveDisableSignUp = this.resolveSsoOnly() ? true : disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
678
808
  return {
679
809
  enabled: this.config.emailAndPassword?.enabled ?? true,
680
810
  ...passwordHasher ? { password: passwordHasher } : {},
@@ -688,28 +818,28 @@ var AuthManager = class {
688
818
  sendResetPassword: async ({ user, url, token }) => {
689
819
  const email = this.getEmailService();
690
820
  if (!email) {
691
- console.warn(
692
- `[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`
821
+ throw new Error(
822
+ `Password-reset email could not be sent to ${user.email}: no email service is configured for this deployment.`
693
823
  );
694
- return;
695
824
  }
696
825
  const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
697
- try {
698
- await email.sendTemplate({
699
- template: "auth.password_reset",
700
- to: { address: user.email, ...user.name ? { name: user.name } : {} },
701
- data: {
702
- user: { name: user.name || user.email, email: user.email, id: user.id },
703
- resetUrl: url,
704
- token,
705
- expiresInMinutes: Math.round(ttlSec / 60),
706
- appName: this.getAppName()
707
- },
708
- relatedObject: "sys_user",
709
- relatedId: user.id
710
- });
711
- } catch (err) {
712
- console.error(`[AuthManager] sendResetPassword failed (swallowed): ${err?.message ?? err}`);
826
+ const result = await email.sendTemplate({
827
+ template: "auth.password_reset",
828
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
829
+ data: {
830
+ user: { name: user.name || user.email, email: user.email, id: user.id },
831
+ resetUrl: url,
832
+ token,
833
+ expiresInMinutes: Math.round(ttlSec / 60),
834
+ appName: this.getAppName()
835
+ },
836
+ relatedObject: "sys_user",
837
+ relatedId: user.id
838
+ });
839
+ if (result?.status === "failed") {
840
+ throw new Error(
841
+ `Password-reset email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
842
+ );
713
843
  }
714
844
  }
715
845
  };
@@ -724,28 +854,28 @@ var AuthManager = class {
724
854
  sendVerificationEmail: async ({ user, url, token }) => {
725
855
  const email = this.getEmailService();
726
856
  if (!email) {
727
- console.warn(
728
- `[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`
857
+ throw new Error(
858
+ `Verification email could not be sent to ${user.email}: no email service is configured for this deployment.`
729
859
  );
730
- return;
731
860
  }
732
861
  const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
733
- try {
734
- await email.sendTemplate({
735
- template: "auth.verify_email",
736
- to: { address: user.email, ...user.name ? { name: user.name } : {} },
737
- data: {
738
- user: { name: user.name || user.email, email: user.email, id: user.id },
739
- verificationUrl: url,
740
- token,
741
- expiresInMinutes: Math.round(ttlSec / 60),
742
- appName: this.getAppName()
743
- },
744
- relatedObject: "sys_user",
745
- relatedId: user.id
746
- });
747
- } catch (err) {
748
- console.error(`[AuthManager] sendVerificationEmail failed (swallowed): ${err?.message ?? err}`);
862
+ const result = await email.sendTemplate({
863
+ template: "auth.verify_email",
864
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
865
+ data: {
866
+ user: { name: user.name || user.email, email: user.email, id: user.id },
867
+ verificationUrl: url,
868
+ token,
869
+ expiresInMinutes: Math.round(ttlSec / 60),
870
+ appName: this.getAppName()
871
+ },
872
+ relatedObject: "sys_user",
873
+ relatedId: user.id
874
+ });
875
+ if (result?.status === "failed") {
876
+ throw new Error(
877
+ `Verification email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
878
+ );
749
879
  }
750
880
  }
751
881
  }
@@ -758,12 +888,18 @@ var AuthManager = class {
758
888
  updateAge: this.config.session?.updateAge || 60 * 60 * 24
759
889
  // 1 day default
760
890
  },
891
+ // ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
892
+ // so better-auth keeps its own defaults otherwise. The settings bind
893
+ // supplies stricter `customRules` for the auth endpoints.
894
+ ...this.config.rateLimit ? { rateLimit: this.config.rateLimit } : {},
761
895
  // better-auth plugins — registered based on AuthPluginConfig flags
762
896
  plugins,
763
897
  // Database hooks (fired by better-auth's adapter writes — these run
764
898
  // for SSO JIT-provisioning too, unlike kernel-level ObjectQL
765
- // middleware which better-auth's adapter bypasses).
766
- ...this.config.databaseHooks ? { databaseHooks: this.config.databaseHooks } : {},
899
+ // middleware which better-auth's adapter bypasses). The framework's
900
+ // identity-source stamp (`account.create.after`) is always composed in,
901
+ // preserving any host-supplied hooks.
902
+ databaseHooks: this.composeDatabaseHooks(this.config.databaseHooks),
767
903
  // Bootstrap bypass for `disableSignUp`. The first-run owner wizard
768
904
  // (`/_account/setup`) calls `POST /auth/sign-up/email` to create
769
905
  // the very first user — if `OS_DISABLE_SIGNUP=true` is set on a
@@ -774,6 +910,127 @@ var AuthManager = class {
774
910
  // sees `userCount > 0` and the toggle is enforced again.
775
911
  hooks: {
776
912
  before: createAuthMiddleware(async (ctx) => {
913
+ if (ctx?.path === "/sign-up/email" || ctx?.path === "/reset-password" || ctx?.path === "/change-password") {
914
+ const candidate = typeof ctx?.body?.password === "string" && ctx.body.password || typeof ctx?.body?.newPassword === "string" && ctx.body.newPassword || "";
915
+ if (candidate) await this.assertPasswordComplexity(candidate);
916
+ if (candidate && (ctx?.path === "/reset-password" || ctx?.path === "/change-password")) {
917
+ const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => void 0);
918
+ if (userId) {
919
+ ctx.context.__osPwChangeUserId = userId;
920
+ const pw = ctx?.context?.password;
921
+ const verify = typeof pw?.verify === "function" ? pw.verify.bind(pw) : void 0;
922
+ const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
923
+ if (oldHash !== void 0) ctx.context.__osPwHistory = { userId, oldHash };
924
+ }
925
+ }
926
+ }
927
+ if (ctx?.path === "/sso/register") {
928
+ const actor = await this.resolveActor(ctx);
929
+ if (actor?.userId) {
930
+ const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId);
931
+ if (!ok) {
932
+ const { APIError } = await import("better-auth/api");
933
+ throw new APIError("FORBIDDEN", {
934
+ message: "Only an organization owner/admin or a platform admin can register an SSO provider.",
935
+ code: "SSO_REGISTER_FORBIDDEN"
936
+ });
937
+ }
938
+ }
939
+ return;
940
+ }
941
+ if (ctx?.path === "/oauth2/authorize" && this.config.oidcAuthorizeGate) {
942
+ const clientId = ctx?.query?.client_id;
943
+ if (clientId) {
944
+ let gateUserId;
945
+ try {
946
+ const { getSessionFromCtx } = await import("better-auth/api");
947
+ const s = await getSessionFromCtx(ctx);
948
+ gateUserId = s?.user?.id ?? s?.session?.userId;
949
+ } catch {
950
+ }
951
+ if (!gateUserId) {
952
+ try {
953
+ const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
954
+ let token;
955
+ const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
956
+ if (bm?.[1]) token = bm[1].trim();
957
+ if (!token) {
958
+ const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
959
+ if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
960
+ }
961
+ if (token) {
962
+ const sess = await ctx.context.adapter.findOne({
963
+ model: "session",
964
+ where: [{ field: "token", value: token }]
965
+ });
966
+ const exp = sess?.expiresAt ?? sess?.expires_at;
967
+ if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
968
+ gateUserId = String(sess.userId ?? sess.user_id ?? "") || void 0;
969
+ }
970
+ }
971
+ } catch {
972
+ }
973
+ }
974
+ if (gateUserId) {
975
+ const allowed = await this.config.oidcAuthorizeGate({
976
+ userId: gateUserId,
977
+ clientId: String(clientId)
978
+ });
979
+ if (!allowed) {
980
+ const { APIError } = await import("better-auth/api");
981
+ throw new APIError("FORBIDDEN", {
982
+ message: "You are not authorized to sign in to this environment.",
983
+ code: "ENV_ACCESS_DENIED"
984
+ });
985
+ }
986
+ }
987
+ }
988
+ return;
989
+ }
990
+ if (ctx?.path === "/delete-user" || ctx?.path === "/admin/remove-user" || ctx?.path === "/admin/ban-user") {
991
+ let isLastLocalCredential = false;
992
+ try {
993
+ const adapter = ctx.context.adapter;
994
+ let targetId = ctx?.body?.userId ?? ctx?.body?.user_id;
995
+ if (!targetId && ctx.path === "/delete-user") {
996
+ const { getSessionFromCtx } = await import("better-auth/api");
997
+ const s = await getSessionFromCtx(ctx).catch(() => null);
998
+ targetId = s?.user?.id ?? s?.session?.userId;
999
+ }
1000
+ if (targetId) {
1001
+ const targetCred = await adapter.findOne({
1002
+ model: "account",
1003
+ where: [
1004
+ { field: "userId", value: targetId },
1005
+ { field: "providerId", value: "credential" }
1006
+ ]
1007
+ });
1008
+ if (targetCred) {
1009
+ const creds = await adapter.findMany({
1010
+ model: "account",
1011
+ where: [{ field: "providerId", value: "credential" }]
1012
+ });
1013
+ const otherHolders = new Set(
1014
+ (creds ?? []).map((a) => a?.userId ?? a?.user_id).filter((id) => id && id !== targetId)
1015
+ );
1016
+ isLastLocalCredential = otherHolders.size === 0;
1017
+ }
1018
+ }
1019
+ } catch {
1020
+ }
1021
+ if (isLastLocalCredential) {
1022
+ const { APIError } = await import("better-auth/api");
1023
+ throw new APIError("CONFLICT", {
1024
+ 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.",
1025
+ code: "LAST_LOCAL_CREDENTIAL"
1026
+ });
1027
+ }
1028
+ }
1029
+ if (ctx?.path === "/sign-in/email") {
1030
+ const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
1031
+ if (email) await this.assertAccountNotLocked(email);
1032
+ return;
1033
+ }
777
1034
  if (ctx?.path !== "/sign-up/email") return;
778
1035
  const ep = ctx?.context?.options?.emailAndPassword;
779
1036
  if (!ep?.disableSignUp) return;
@@ -788,7 +1045,54 @@ var AuthManager = class {
788
1045
  }
789
1046
  }),
790
1047
  after: createAuthMiddleware(async (ctx) => {
1048
+ if (ctx?.path === "/sign-in/email") {
1049
+ const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
1050
+ if (email) {
1051
+ let succeeded = true;
1052
+ try {
1053
+ const { isAPIError } = await import("better-auth/api");
1054
+ succeeded = !isAPIError(ctx?.context?.returned);
1055
+ } catch {
1056
+ succeeded = !(ctx?.context?.returned instanceof Error);
1057
+ }
1058
+ await this.recordSignInOutcome(email, succeeded);
1059
+ if (succeeded) {
1060
+ const uid = ctx?.context?.returned?.user?.id;
1061
+ if (typeof uid === "string") await this.enforceConcurrentCap(uid);
1062
+ }
1063
+ }
1064
+ return;
1065
+ }
1066
+ if (ctx?.path === "/change-password" || ctx?.path === "/reset-password") {
1067
+ let succeeded;
1068
+ try {
1069
+ const { isAPIError } = await import("better-auth/api");
1070
+ succeeded = !isAPIError(ctx?.context?.returned);
1071
+ } catch {
1072
+ succeeded = !(ctx?.context?.returned instanceof Error);
1073
+ }
1074
+ if (succeeded) {
1075
+ const stampId = ctx?.context?.__osPwChangeUserId;
1076
+ if (stampId) await this.stampPasswordChangedAt(stampId);
1077
+ const stash = ctx?.context?.__osPwHistory;
1078
+ if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
1079
+ }
1080
+ delete ctx.context.__osPwChangeUserId;
1081
+ delete ctx.context.__osPwHistory;
1082
+ return;
1083
+ }
791
1084
  if (ctx?.path !== "/sign-up/email") return;
1085
+ {
1086
+ const newUserId = ctx?.context?.returned?.user?.id;
1087
+ let signupOk;
1088
+ try {
1089
+ const { isAPIError } = await import("better-auth/api");
1090
+ signupOk = !isAPIError(ctx?.context?.returned);
1091
+ } catch {
1092
+ signupOk = !(ctx?.context?.returned instanceof Error);
1093
+ }
1094
+ if (signupOk && typeof newUserId === "string") await this.stampPasswordChangedAt(newUserId);
1095
+ }
792
1096
  const ep = ctx?.context?.options?.emailAndPassword;
793
1097
  if (ep && ctx.context.__osDisableSignUpOrig !== void 0) {
794
1098
  ep.disableSignUp = ctx.context.__osDisableSignUpOrig;
@@ -800,7 +1104,7 @@ var AuthManager = class {
800
1104
  // Auto-includes origins from OS_CORS_ORIGIN env var so CORS and CSRF stay in sync.
801
1105
  ...(() => {
802
1106
  const origins = [...this.config.trustedOrigins || []];
803
- const corsOrigin = (0, import_types.readEnvWithDeprecation)("OS_CORS_ORIGIN", "CORS_ORIGIN");
1107
+ const corsOrigin = (0, import_types.readEnvWithDeprecation)("OS_CORS_ORIGIN", "CORS_ORIGIN", { silent: true });
804
1108
  if (corsOrigin && corsOrigin !== "*") {
805
1109
  corsOrigin.split(",").map((s) => s.trim()).filter(Boolean).forEach((o) => {
806
1110
  if (!origins.includes(o)) origins.push(o);
@@ -811,6 +1115,20 @@ var AuthManager = class {
811
1115
  origins.push("http://*.localhost:*");
812
1116
  origins.push("https://*.localhost:*");
813
1117
  }
1118
+ if (this.isSsoWired()) {
1119
+ return {
1120
+ trustedOrigins: async (request) => {
1121
+ const base = [...origins];
1122
+ try {
1123
+ for (const o of await this.ssoDiscoveryTrustedOrigins(request)) {
1124
+ if (!base.includes(o)) base.push(o);
1125
+ }
1126
+ } catch {
1127
+ }
1128
+ return base;
1129
+ }
1130
+ };
1131
+ }
814
1132
  return origins.length ? { trustedOrigins: origins } : {};
815
1133
  })(),
816
1134
  // Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
@@ -886,17 +1204,25 @@ var AuthManager = class {
886
1204
  async buildPluginList() {
887
1205
  const pluginConfig = this.config.plugins ?? {};
888
1206
  const plugins = [];
889
- const oidcEnv = globalThis?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
890
- const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === "true" : void 0;
1207
+ const oidcFromEnv = readBooleanEnv("OS_OIDC_PROVIDER_ENABLED");
1208
+ const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
1209
+ const scimFromEnv = readBooleanEnv("OS_SCIM_ENABLED");
1210
+ const ssoDomainVerifyFromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
1211
+ const scimEffective = scimFromEnv ?? pluginConfig.scim ?? false;
891
1212
  const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
1213
+ const hibpFromEnv = readBooleanEnv("OS_AUTH_PASSWORD_REJECT_BREACHED");
892
1214
  const enabled = {
893
1215
  organization: pluginConfig.organization ?? true,
894
1216
  twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false,
1217
+ passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false,
895
1218
  passkeys: pluginConfig.passkeys ?? false,
896
1219
  magicLink: pluginConfig.magicLink ?? false,
897
1220
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
898
1221
  deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
899
- admin: pluginConfig.admin ?? false
1222
+ admin: pluginConfig.admin ?? scimEffective,
1223
+ sso: ssoFromEnv ?? pluginConfig.sso ?? false,
1224
+ ssoDomainVerification: ssoDomainVerifyFromEnv ?? pluginConfig.ssoDomainVerification ?? false,
1225
+ scim: scimEffective
900
1226
  };
901
1227
  const { bearer } = await import("better-auth/plugins/bearer");
902
1228
  plugins.push(bearer());
@@ -939,6 +1265,28 @@ var AuthManager = class {
939
1265
  // the built-in /accept-invitation route usable for pilots; operators
940
1266
  // who wire a real mailer can re-enable downstream.
941
1267
  requireEmailVerificationOnInvitation: false,
1268
+ // Cap how many orgs a user can CREATE (OS_ORG_LIMIT). Counts only orgs
1269
+ // the user OWNS (role=owner) — never orgs they were merely invited into —
1270
+ // so a generous cap stops scripted org/free-env spam (each new org can
1271
+ // auto-provision a free environment on the cloud control plane) WITHOUT
1272
+ // ever blocking a collaborator who belongs to many orgs. Unset → no
1273
+ // limit (self-host default). Fail-open: if the count can't be taken we
1274
+ // allow creation rather than block a legitimate user on an infra hiccup.
1275
+ organizationLimit: async (user) => {
1276
+ const limit = (0, import_types.resolveOrgLimit)();
1277
+ if (limit == null) return false;
1278
+ const engine = this.config.dataEngine;
1279
+ const uid = typeof user?.id === "string" ? user.id : "";
1280
+ if (!engine || !uid) return false;
1281
+ try {
1282
+ const owned = await withSystemReadContext(engine).count("sys_member", {
1283
+ where: { user_id: uid, role: "owner" }
1284
+ });
1285
+ return (owned ?? 0) >= limit;
1286
+ } catch {
1287
+ return false;
1288
+ }
1289
+ },
942
1290
  ...customOrgRoles ? { roles: customOrgRoles } : {},
943
1291
  // ── Slug-change guard ─────────────────────────────────────
944
1292
  // An org's slug is baked into every env hostname at creation
@@ -957,21 +1305,35 @@ var AuthManager = class {
957
1305
  // The plugin itself is always installed (so list/update/invite endpoints
958
1306
  // keep responding); only the `create` operation is denied when the
959
1307
  // deployment is provisioned in single-org mode. Resolution order:
960
- // 1. explicit `OS_MULTI_ORG_ENABLED` (wins for backwards compat),
961
- // 2. else `OS_MULTI_TENANT` (multi-tenant deployments are always
962
- // multi-org), default `'false'` → single-org / per-env runtime.
1308
+ // `OS_MULTI_ORG_ENABLED` (default `'false'` single-org /
1309
+ // per-env runtime).
963
1310
  beforeCreateOrganization: async () => {
964
- const env = globalThis?.process?.env ?? {};
965
- const explicit = env.OS_MULTI_ORG_ENABLED;
966
- const legacy = explicit === void 0 ? (0, import_types.readEnvWithDeprecation)("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") : explicit;
967
- const flag = String(legacy ?? "false").toLowerCase();
968
- if (flag === "false") {
1311
+ if (!(0, import_types.resolveMultiOrgEnabled)()) {
969
1312
  const { APIError } = await import("better-auth/api");
970
1313
  throw new APIError("FORBIDDEN", {
971
1314
  message: "Creating additional organizations is disabled on this deployment."
972
1315
  });
973
1316
  }
974
1317
  },
1318
+ // Run host-provided org-creation side effects (e.g. the cloud control
1319
+ // plane provisions the org's born-with production environment). The
1320
+ // org-plugin's models don't fire core databaseHooks, so this is the
1321
+ // only server-side seam for "every org is born with its prod env".
1322
+ // Failure-isolated: org creation must not roll back on a side-effect miss.
1323
+ afterCreateOrganization: async ({ organization: organization2, member, user }) => {
1324
+ const cb = this.config.onOrganizationCreated;
1325
+ if (typeof cb !== "function") return;
1326
+ try {
1327
+ await cb({
1328
+ organizationId: organization2?.id,
1329
+ userId: user?.id ?? member?.userId,
1330
+ name: organization2?.name,
1331
+ slug: organization2?.slug
1332
+ });
1333
+ } catch (err) {
1334
+ console.warn("[auth] onOrganizationCreated callback failed:", err?.message ?? String(err));
1335
+ }
1336
+ },
975
1337
  beforeUpdateOrganization: async ({ organization: organization2, member }) => {
976
1338
  const newSlug = organization2?.slug;
977
1339
  const orgId = member?.organizationId;
@@ -1049,6 +1411,12 @@ var AuthManager = class {
1049
1411
  schema: buildTwoFactorPluginSchema()
1050
1412
  }));
1051
1413
  }
1414
+ if (enabled.passwordRejectBreached) {
1415
+ const { haveIBeenPwned } = await import("better-auth/plugins/haveibeenpwned");
1416
+ plugins.push(haveIBeenPwned({
1417
+ customPasswordCompromisedMessage: "This password has appeared in a known data breach. Please choose a different one."
1418
+ }));
1419
+ }
1052
1420
  if (enabled.admin) {
1053
1421
  const { admin } = await import("better-auth/plugins/admin");
1054
1422
  plugins.push(admin({
@@ -1116,6 +1484,17 @@ var AuthManager = class {
1116
1484
  schema: buildOauthProviderPluginSchema()
1117
1485
  }));
1118
1486
  }
1487
+ if (enabled.sso) {
1488
+ const { sso } = await import("@better-auth/sso");
1489
+ plugins.push(sso({
1490
+ organizationProvisioning: { defaultRole: "member" },
1491
+ ...enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}
1492
+ }));
1493
+ }
1494
+ if (enabled.scim) {
1495
+ const { scim } = await import("@better-auth/scim");
1496
+ plugins.push(scim({ storeSCIMToken: "hashed" }));
1497
+ }
1119
1498
  if (enabled.deviceAuthorization) {
1120
1499
  const { deviceAuthorization } = await import("better-auth/plugins/device-authorization");
1121
1500
  const baseUrl = (this.config.baseUrl ?? "").replace(/\/$/, "");
@@ -1152,30 +1531,45 @@ var AuthManager = class {
1152
1531
  return false;
1153
1532
  }
1154
1533
  };
1155
- const isActiveOrgAdmin = async () => {
1534
+ const activeOrgRoles = async () => {
1156
1535
  try {
1157
1536
  const orgId = session?.activeOrganizationId;
1158
- if (!orgId) return false;
1537
+ if (!orgId) return [];
1159
1538
  const members = await dataEngine.find("sys_member", {
1160
1539
  where: { user_id: user.id, organization_id: orgId },
1161
1540
  limit: 5
1162
1541
  });
1163
- return (Array.isArray(members) ? members : []).some((m) => {
1542
+ const out = [];
1543
+ for (const m of Array.isArray(members) ? members : []) {
1164
1544
  const raw = typeof m?.role === "string" ? m.role : "";
1165
- const roles2 = raw.split(",").map((s) => s.trim().toLowerCase());
1166
- return roles2.includes("owner") || roles2.includes("admin");
1167
- });
1545
+ for (const r of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
1546
+ const mapped = (0, import_spec.mapMembershipRole)(r);
1547
+ if (!out.includes(mapped)) out.push(mapped);
1548
+ }
1549
+ }
1550
+ return out;
1168
1551
  } catch {
1169
- return false;
1552
+ return [];
1170
1553
  }
1171
1554
  };
1172
1555
  const platformAdmin = await isPlatformAdmin();
1173
- const promote = platformAdmin || await isActiveOrgAdmin();
1556
+ const orgRoles = await activeOrgRoles();
1174
1557
  const storedRole = typeof user.role === "string" ? user.role : "";
1175
- const roles = storedRole.split(",").map((s) => s.trim()).filter(Boolean);
1176
- if (promote && !roles.includes("admin")) roles.push("admin");
1177
- if (!promote) return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
1178
- return { user: { ...user, role: "admin", roles, isPlatformAdmin: platformAdmin }, session };
1558
+ const roles = Array.from(/* @__PURE__ */ new Set([
1559
+ ...storedRole.split(",").map((s) => s.trim()).filter(Boolean),
1560
+ ...orgRoles,
1561
+ ...platformAdmin ? [import_spec.BUILTIN_ROLE_PLATFORM_ADMIN] : []
1562
+ ]));
1563
+ await this.enforceSessionControls(session?.id, session?.createdAt);
1564
+ const authGate = await this.computeAuthGate(
1565
+ user.id,
1566
+ session?.activeOrganizationId,
1567
+ user?.twoFactorEnabled === true
1568
+ );
1569
+ return {
1570
+ user: { ...user, roles, isPlatformAdmin: platformAdmin, ...authGate ? { authGate } : {} },
1571
+ session
1572
+ };
1179
1573
  }));
1180
1574
  }
1181
1575
  return plugins;
@@ -1205,7 +1599,7 @@ var AuthManager = class {
1205
1599
  * Generate a secure secret if not provided
1206
1600
  */
1207
1601
  generateSecret() {
1208
- const envSecret = (0, import_types.readEnvWithDeprecation)("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"]);
1602
+ const envSecret = (0, import_types.readEnvWithDeprecation)("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"], { silent: true });
1209
1603
  if (envSecret) return envSecret;
1210
1604
  if (process.env.NODE_ENV === "production") {
1211
1605
  throw new Error(
@@ -1374,6 +1768,18 @@ var AuthManager = class {
1374
1768
  // `sys_device_code`. Enable via `plugins.deviceAuthorization: true` in
1375
1769
  // AuthPluginConfig.
1376
1770
  // ---------------------------------------------------------------------------
1771
+ /**
1772
+ * SSO-only ("enforced") login mode: the login UI hides the local password
1773
+ * form + self-registration so the team signs in via the IdP only.
1774
+ * `OS_AUTH_SSO_ONLY` (when set) wins over the `ssoOnlyMode` config knob —
1775
+ * parity with the `disableSignUp` env override — so a deployment can force
1776
+ * it regardless of the per-env/config value. Break-glass is preserved: this
1777
+ * NEVER disables `emailAndPassword.enabled`; it only forces `disableSignUp`
1778
+ * and signals the UI to hide the password form. Generic over the IdP.
1779
+ */
1780
+ resolveSsoOnly() {
1781
+ return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false);
1782
+ }
1377
1783
  getPublicConfig() {
1378
1784
  const socialProviders = [];
1379
1785
  if (this.config.socialProviders) {
@@ -1411,16 +1817,15 @@ var AuthManager = class {
1411
1817
  }
1412
1818
  const emailPasswordConfig = this.config.emailAndPassword ?? {};
1413
1819
  const disableSignUpFromEnv = readDisableSignUpEnv();
1820
+ const ssoOnly = this.resolveSsoOnly();
1414
1821
  const emailPassword = {
1415
1822
  enabled: emailPasswordConfig.enabled !== false,
1416
1823
  // Default to true
1417
- disableSignUp: disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
1824
+ disableSignUp: ssoOnly ? true : disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
1418
1825
  requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false
1419
1826
  };
1420
1827
  const pluginConfig = this.config.plugins ?? {};
1421
- const multiOrgEnv = globalThis?.process?.env ?? {};
1422
- const multiOrgRaw = multiOrgEnv.OS_MULTI_ORG_ENABLED !== void 0 ? multiOrgEnv.OS_MULTI_ORG_ENABLED : (0, import_types.readEnvWithDeprecation)("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") ?? "false";
1423
- const multiOrgEnabled = String(multiOrgRaw).toLowerCase() !== "false";
1828
+ const multiOrgEnabled = (0, import_types.resolveMultiOrgEnabled)();
1424
1829
  const DEFAULT_TERMS_URL = "https://objectstack.ai/terms";
1425
1830
  const DEFAULT_PRIVACY_URL = "https://objectstack.ai/privacy";
1426
1831
  const rawTermsUrl = globalThis?.process?.env?.OS_TERMS_URL;
@@ -1443,6 +1848,15 @@ var AuthManager = class {
1443
1848
  organization: pluginConfig.organization ?? true,
1444
1849
  multiOrgEnabled,
1445
1850
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
1851
+ // Coarse "is the @better-auth/sso plugin wired" flag. The `/auth/config`
1852
+ // route refines this to "usable" (≥1 provider configured) via
1853
+ // `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button
1854
+ // both when SSO is off AND when it's on but no IdP exists yet.
1855
+ sso: this.isSsoWired(),
1856
+ // SSO-only ("enforced"): tell the login UI to hide the local password
1857
+ // form + self-registration. A break-glass "use a password" link remains
1858
+ // for the env owner / local admin. Driven by `ssoOnlyMode` / `OS_AUTH_SSO_ONLY`.
1859
+ ssoEnforced: ssoOnly,
1446
1860
  deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
1447
1861
  admin: pluginConfig.admin ?? false,
1448
1862
  ...termsUrl ? { termsUrl } : {},
@@ -1454,6 +1868,712 @@ var AuthManager = class {
1454
1868
  features
1455
1869
  };
1456
1870
  }
1871
+ /**
1872
+ * Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag.
1873
+ * Resolved with the EXACT logic that decides whether the plugin is mounted
1874
+ * in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
1875
+ * advertised capability can never disagree with the actual `/sign-in/sso`
1876
+ * route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
1877
+ * Public so `AuthPlugin` can gate the Setup-nav "SSO Providers" entry on it
1878
+ * (captures both self-host `OS_SSO_ENABLED` and the cloud per-env
1879
+ * `planAllowsSso` config, since that arrives via `plugins.sso`).
1880
+ */
1881
+ isSsoWired() {
1882
+ const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
1883
+ return ssoFromEnv ?? this.config.plugins?.sso ?? false;
1884
+ }
1885
+ /**
1886
+ * Whether opt-in DNS domain-verification (ADR-0024 ②) is wired — i.e. the
1887
+ * `/sso/request-domain-verification` + `/sso/verify-domain` endpoints are
1888
+ * mounted (and the hard "domain must be verified to log in" gate is active).
1889
+ * Resolved with the EXACT logic `buildPluginList` uses for the `sso()`
1890
+ * `domainVerification.enabled` option, so the bridge can return a clear
1891
+ * "not enabled for this environment" instead of a bare 404 when off.
1892
+ * Implies `isSsoWired()` (the sso plugin must be loaded to honor it).
1893
+ */
1894
+ isSsoDomainVerificationEnabled() {
1895
+ if (!this.isSsoWired()) return false;
1896
+ const fromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
1897
+ return fromEnv ?? this.config.plugins?.ssoDomainVerification ?? false;
1898
+ }
1899
+ /**
1900
+ * Whether enterprise SSO is actually *usable*, not merely wired: the plugin
1901
+ * is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
1902
+ * matching still happens at `/sign-in/sso`; this answers the coarser "is
1903
+ * there any point showing the SSO button at all", so a freshly-enabled but
1904
+ * unconfigured SSO setup doesn't advertise a button that errors for everyone.
1905
+ *
1906
+ * Fails OPEN to the wired flag when providers can't be counted (no data
1907
+ * engine, query error) — a config-introspection hiccup must never make the
1908
+ * login page hide a button that genuinely works.
1909
+ */
1910
+ async isSsoUsable() {
1911
+ if (!this.isSsoWired()) return false;
1912
+ const engine = this.getDataEngine();
1913
+ if (!engine) return true;
1914
+ try {
1915
+ const count = await withSystemReadContext(engine).count("sys_sso_provider");
1916
+ return typeof count === "number" ? count > 0 : true;
1917
+ } catch {
1918
+ return true;
1919
+ }
1920
+ }
1921
+ /**
1922
+ * Extra `trustedOrigins` entries derived from an external-SSO registration
1923
+ * request. For a `POST /sso/register` | `/sso/update-provider`, parse the
1924
+ * (cloned) body and return the PUBLIC-ROUTABLE origins of the declared
1925
+ * `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery
1926
+ * validation accepts a customer IdP registered at runtime (ADR-0024) without
1927
+ * the operator pre-listing it in boot config. Only public-routable hosts are
1928
+ * returned — private / internal / loopback hosts are never auto-trusted
1929
+ * (better-auth's `isPublicRoutableHost`, the same predicate its own
1930
+ * sub-endpoint check uses). Best-effort: any parse error yields `[]`.
1931
+ */
1932
+ async ssoDiscoveryTrustedOrigins(request) {
1933
+ try {
1934
+ const req = request;
1935
+ if (!req || typeof req.clone !== "function" || !req.url) return [];
1936
+ if ((req.method ?? "GET").toUpperCase() !== "POST") return [];
1937
+ const path = new URL(req.url).pathname;
1938
+ if (!/\/sso\/(register|update-provider)$/.test(path)) return [];
1939
+ const body = await req.clone().json().catch(() => null);
1940
+ if (!body || typeof body !== "object") return [];
1941
+ const oidc = body.oidcConfig ?? {};
1942
+ const candidates = [
1943
+ body.issuer,
1944
+ oidc.discoveryEndpoint,
1945
+ oidc.authorizationEndpoint,
1946
+ oidc.tokenEndpoint,
1947
+ oidc.jwksEndpoint,
1948
+ oidc.userInfoEndpoint
1949
+ ].filter((v) => typeof v === "string" && v.length > 0);
1950
+ if (!candidates.length) return [];
1951
+ const { isPublicRoutableHost } = await import("@better-auth/core/utils/host");
1952
+ const out = [];
1953
+ for (const c of candidates) {
1954
+ try {
1955
+ const u = new URL(c);
1956
+ if (isPublicRoutableHost(u.hostname) && !out.includes(u.origin)) out.push(u.origin);
1957
+ } catch {
1958
+ }
1959
+ }
1960
+ return out;
1961
+ } catch {
1962
+ return [];
1963
+ }
1964
+ }
1965
+ /**
1966
+ * Resolve the acting user (+ their active org) for a before-hook gate,
1967
+ * hook-order-independent. Tries the standard cookie session first, then falls
1968
+ * back to explicit token resolution (bearer or the session cookie's token
1969
+ * part) — the bearer plugin may convert `Authorization: Bearer` to a session
1970
+ * AFTER this global before-hook runs. Returns `null` when no valid session
1971
+ * can be resolved (→ caller lets `sessionMiddleware` issue the 401).
1972
+ */
1973
+ async resolveActor(ctx) {
1974
+ try {
1975
+ const { getSessionFromCtx } = await import("better-auth/api");
1976
+ const s = await getSessionFromCtx(ctx);
1977
+ const userId = s?.user?.id ?? s?.session?.userId;
1978
+ if (userId) {
1979
+ return {
1980
+ userId: String(userId),
1981
+ activeOrgId: s?.session?.activeOrganizationId ?? s?.activeOrganizationId ?? void 0
1982
+ };
1983
+ }
1984
+ } catch {
1985
+ }
1986
+ try {
1987
+ const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
1988
+ let token;
1989
+ const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
1990
+ if (bm?.[1]) token = bm[1].trim();
1991
+ if (!token) {
1992
+ const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
1993
+ if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
1994
+ }
1995
+ if (token) {
1996
+ const sess = await ctx.context.adapter.findOne({
1997
+ model: "session",
1998
+ where: [{ field: "token", value: token }]
1999
+ });
2000
+ const exp = sess?.expiresAt ?? sess?.expires_at;
2001
+ if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
2002
+ const userId = String(sess.userId ?? sess.user_id ?? "");
2003
+ if (userId) {
2004
+ return {
2005
+ userId,
2006
+ activeOrgId: sess.activeOrganizationId ?? sess.active_organization_id ?? void 0
2007
+ };
2008
+ }
2009
+ }
2010
+ }
2011
+ } catch {
2012
+ }
2013
+ return null;
2014
+ }
2015
+ /**
2016
+ * True when `userId` is a platform admin (a `sys_user_permission_set` row
2017
+ * pointing at `admin_full_access` with `organization_id = null`) OR an
2018
+ * owner/admin member of `activeOrgId` (any org membership with role
2019
+ * owner/admin when no active org is set). Mirrors the role-derivation in
2020
+ * `customSession`; reads through `withSystemReadContext` so the lookups are
2021
+ * not themselves RLS-scoped to the acting (possibly non-privileged) user.
2022
+ * Fails CLOSED (returns false) on any lookup error — this backs a security
2023
+ * gate, so an unverifiable actor must never pass.
2024
+ */
2025
+ async isOrgOrPlatformAdmin(userId, activeOrgId) {
2026
+ const engine = this.getDataEngine();
2027
+ if (!engine) return false;
2028
+ const sys = withSystemReadContext(engine);
2029
+ try {
2030
+ const links = await sys.find("sys_user_permission_set", {
2031
+ where: { user_id: userId },
2032
+ limit: 50
2033
+ });
2034
+ const platformLinks = (Array.isArray(links) ? links : []).filter(
2035
+ (l) => !l.organization_id
2036
+ );
2037
+ if (platformLinks.length) {
2038
+ const sets = await sys.find("sys_permission_set", { limit: 50 });
2039
+ const adminSet = (Array.isArray(sets) ? sets : []).find(
2040
+ (r) => r.name === "admin_full_access"
2041
+ );
2042
+ if (adminSet && platformLinks.some((l) => l.permission_set_id === adminSet.id)) {
2043
+ return true;
2044
+ }
2045
+ }
2046
+ const where = { user_id: userId };
2047
+ if (activeOrgId) where.organization_id = activeOrgId;
2048
+ const members = await sys.find("sys_member", { where, limit: 10 });
2049
+ for (const m of Array.isArray(members) ? members : []) {
2050
+ const raw = typeof m?.role === "string" ? m.role : "";
2051
+ if (raw.split(",").map((s) => s.trim()).some((r) => r === "owner" || r === "admin")) {
2052
+ return true;
2053
+ }
2054
+ }
2055
+ return false;
2056
+ } catch {
2057
+ return false;
2058
+ }
2059
+ }
2060
+ /**
2061
+ * Compose the framework's identity-source stamp (`account.create.after`)
2062
+ * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes
2063
+ * `user.create.after` (personal-org provisioning) + `session.create.before`
2064
+ * (active-org) — different model/op, so no collision — but if a host ever
2065
+ * adds its own `account.create.after` we chain it after the stamp rather
2066
+ * than silently dropping one.
2067
+ */
2068
+ composeDatabaseHooks(host) {
2069
+ const stamp = (account, ctx) => this.stampIdentitySource(account, ctx);
2070
+ const hostAccountAfter = host?.account?.create?.after;
2071
+ const after = hostAccountAfter ? async (account, ctx) => {
2072
+ await stamp(account, ctx);
2073
+ return hostAccountAfter(account, ctx);
2074
+ } : stamp;
2075
+ return {
2076
+ ...host ?? {},
2077
+ account: {
2078
+ ...host?.account ?? {},
2079
+ create: {
2080
+ ...host?.account?.create ?? {},
2081
+ after
2082
+ }
2083
+ }
2084
+ };
2085
+ }
2086
+ /**
2087
+ * Maintain `sys_user.source` (ADR-0024 D4 provenance) as accounts are linked.
2088
+ * Drives the managed-vs-native user-mgmt gating: a managed (`idp-provisioned`)
2089
+ * user holds no local credential, so the password / identity-edit actions
2090
+ * hide for them — preventing a managed user from self-minting a local
2091
+ * password that would bypass enforced SSO.
2092
+ *
2093
+ * Two cases, both break-glass safe and idempotent (only writes on a real
2094
+ * change, so trackHistory stays quiet):
2095
+ *
2096
+ * • A **federated** account (any non-`credential` provider — the cloud-as-IdP
2097
+ * `objectstack-cloud` provider OR a customer's own OIDC/SAML IdP) is
2098
+ * linked AND the user holds NO local credential → mark `idp-provisioned`.
2099
+ * A user who already has a `credential` account (an env-native user who
2100
+ * linked SSO) is left `env-native` — they keep a usable password.
2101
+ *
2102
+ * • A **credential** account is created (local signup, or the break-glass
2103
+ * owner's password set via set-initial-password — which can land AFTER the
2104
+ * first SSO link) → ensure `env-native`. This flips a previously-stamped
2105
+ * owner back, so the break-glass admin never loses self-service password
2106
+ * management.
2107
+ *
2108
+ * Best-effort: any failure leaves the prior value (the gate fails open — a
2109
+ * managed user might transiently show a password action that simply errors —
2110
+ * never a hard login failure).
2111
+ */
2112
+ async stampIdentitySource(account, _ctx) {
2113
+ try {
2114
+ const providerId = account?.providerId ?? account?.provider_id;
2115
+ const userId = account?.userId ?? account?.user_id;
2116
+ if (!userId || !providerId) return;
2117
+ const engine = this.getDataEngine();
2118
+ if (!engine) return;
2119
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2120
+ if (providerId === "credential") {
2121
+ const u = await engine.findOne("sys_user", {
2122
+ filter: { id: userId },
2123
+ fields: ["id", "source"],
2124
+ context: SYSTEM_CTX
2125
+ });
2126
+ if (u && u.source === "idp_provisioned") {
2127
+ await engine.update("sys_user", { id: userId, source: "env_native" }, { context: SYSTEM_CTX });
2128
+ }
2129
+ return;
2130
+ }
2131
+ const credentialCount = await engine.count("sys_account", {
2132
+ filter: { user_id: userId, provider_id: "credential" },
2133
+ context: SYSTEM_CTX
2134
+ });
2135
+ if (typeof credentialCount === "number" && credentialCount > 0) return;
2136
+ await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
2137
+ } catch {
2138
+ }
2139
+ }
2140
+ /**
2141
+ * ADR-0069 D1 — reject a password that doesn't meet the configured character-
2142
+ * class complexity. No-op when `passwordRequireComplexity` is off. Counts the
2143
+ * four classes (upper / lower / digit / symbol) present and throws
2144
+ * `PASSWORD_POLICY_VIOLATION` when fewer than `passwordMinClasses` are used.
2145
+ */
2146
+ async assertPasswordComplexity(password) {
2147
+ if (!this.config.passwordRequireComplexity) return;
2148
+ const min = Math.min(4, Math.max(1, Math.floor(Number(this.config.passwordMinClasses) || 3)));
2149
+ 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);
2150
+ if (classes < min) {
2151
+ const { APIError } = await import("better-auth/api");
2152
+ throw new APIError("BAD_REQUEST", {
2153
+ message: `Password must include at least ${min} of: uppercase, lowercase, digit, symbol.`,
2154
+ code: "PASSWORD_POLICY_VIOLATION"
2155
+ });
2156
+ }
2157
+ }
2158
+ /**
2159
+ * ADR-0069 — is any authentication-policy gate enabled? Cheap, synchronous;
2160
+ * lets the transport seams skip session lookups entirely when off (the
2161
+ * default), keeping the gate zero-overhead until an admin opts in.
2162
+ */
2163
+ isAuthGateActive() {
2164
+ this.refreshOrgMfaCacheIfStale();
2165
+ return Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || this.config.mfaRequired === true || this._orgMfaCache.value;
2166
+ }
2167
+ /**
2168
+ * ADR-0069 — refresh the "any org requires MFA" cache in the background when
2169
+ * stale (60s TTL). Fire-and-forget: a brand-new per-org requirement activates
2170
+ * the gate on the next request, never blocking this one. No-op when global MFA
2171
+ * is already on (the gate is active regardless).
2172
+ */
2173
+ refreshOrgMfaCacheIfStale() {
2174
+ if (this.config.mfaRequired === true) return;
2175
+ if (this._orgMfaRefreshing) return;
2176
+ if (Date.now() - this._orgMfaCache.at < 6e4) return;
2177
+ const engine = this.getDataEngine();
2178
+ if (!engine) return;
2179
+ this._orgMfaRefreshing = true;
2180
+ void (async () => {
2181
+ try {
2182
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2183
+ const n = await engine.count("sys_organization", {
2184
+ where: { require_mfa: true },
2185
+ context: SYSTEM_CTX
2186
+ });
2187
+ this._orgMfaCache = { value: typeof n === "number" && n > 0, at: Date.now() };
2188
+ } catch {
2189
+ } finally {
2190
+ this._orgMfaRefreshing = false;
2191
+ }
2192
+ })();
2193
+ }
2194
+ /**
2195
+ * ADR-0069 — compute the auth-policy gate posture for a session. Returns an
2196
+ * `{ code, message }` when the user is currently blocked (e.g. password
2197
+ * expired), else undefined. No-op (and no DB read) when no gate feature is
2198
+ * enabled. Fails OPEN on any lookup error — a transient hiccup must never lock
2199
+ * a compliant user out.
2200
+ */
2201
+ async computeAuthGate(userId, _activeOrgId, _twoFactorEnabledHint) {
2202
+ const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0);
2203
+ const mfaGlobal = this.config.mfaRequired === true;
2204
+ const orgMaybeRequires = !mfaGlobal && !!_activeOrgId && this._orgMfaCache.value;
2205
+ if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires) return void 0;
2206
+ const engine = this.getDataEngine();
2207
+ if (!engine || !userId) return void 0;
2208
+ try {
2209
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2210
+ const u = await engine.findOne("sys_user", {
2211
+ where: { id: userId },
2212
+ fields: ["password_changed_at", "two_factor_enabled", "mfa_required_at"],
2213
+ context: SYSTEM_CTX
2214
+ });
2215
+ let mfaRequired = mfaGlobal;
2216
+ if (!mfaRequired && orgMaybeRequires) {
2217
+ const org = await engine.findOne("sys_organization", {
2218
+ where: { id: _activeOrgId },
2219
+ fields: ["require_mfa"],
2220
+ context: SYSTEM_CTX
2221
+ });
2222
+ mfaRequired = org?.require_mfa === true || org?.require_mfa === 1;
2223
+ }
2224
+ if (expiryDays > 0) {
2225
+ const changed = u?.password_changed_at;
2226
+ if (changed && Date.now() - new Date(changed).getTime() > expiryDays * 864e5) {
2227
+ return {
2228
+ code: "PASSWORD_EXPIRED",
2229
+ message: "Your password has expired. Please change it to continue."
2230
+ };
2231
+ }
2232
+ }
2233
+ if (mfaRequired && !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1)) {
2234
+ const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7)));
2235
+ let requiredAt = u?.mfa_required_at;
2236
+ if (!requiredAt) {
2237
+ requiredAt = /* @__PURE__ */ new Date();
2238
+ engine.update("sys_user", { id: userId, mfa_required_at: requiredAt }, { context: SYSTEM_CTX }).catch(() => void 0);
2239
+ }
2240
+ const elapsedMs = Date.now() - new Date(requiredAt).getTime();
2241
+ if (elapsedMs > graceDays * 864e5) {
2242
+ return {
2243
+ code: "MFA_REQUIRED",
2244
+ message: "Multi-factor authentication is required. Please set up an authenticator app to continue."
2245
+ };
2246
+ }
2247
+ }
2248
+ } catch {
2249
+ return void 0;
2250
+ }
2251
+ return void 0;
2252
+ }
2253
+ /**
2254
+ * ADR-0069 D1 — stamp `sys_user.password_changed_at = now` after a password is
2255
+ * set (sign-up / change / reset). Best-effort; never throws. Written as a Date
2256
+ * (never epoch-ms) per ADR-0074.
2257
+ */
2258
+ async stampPasswordChangedAt(userId) {
2259
+ const engine = this.getDataEngine();
2260
+ if (!engine || !userId) return;
2261
+ try {
2262
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2263
+ await engine.update(
2264
+ "sys_user",
2265
+ { id: userId, password_changed_at: /* @__PURE__ */ new Date() },
2266
+ { context: SYSTEM_CTX }
2267
+ );
2268
+ } catch {
2269
+ }
2270
+ }
2271
+ /**
2272
+ * ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
2273
+ * a string[] of hashes, tolerating null / malformed values.
2274
+ */
2275
+ parseHashes(raw) {
2276
+ if (typeof raw !== "string" || !raw.trim()) return [];
2277
+ try {
2278
+ const arr = JSON.parse(raw);
2279
+ return Array.isArray(arr) ? arr.filter((h) => typeof h === "string" && !!h) : [];
2280
+ } catch {
2281
+ return [];
2282
+ }
2283
+ }
2284
+ /**
2285
+ * ADR-0069 D1 — resolve the user whose password is being changed. For
2286
+ * `/change-password` the caller is authenticated (session); for
2287
+ * `/reset-password` the user is carried by the reset token's verification
2288
+ * value (the same lookup better-auth's own handler uses).
2289
+ */
2290
+ async resolvePasswordChangeUserId(ctx) {
2291
+ if (ctx?.path === "/change-password") {
2292
+ const { getSessionFromCtx } = await import("better-auth/api");
2293
+ const sess = await getSessionFromCtx(ctx).catch(() => null);
2294
+ return sess?.user?.id ?? sess?.session?.userId ?? void 0;
2295
+ }
2296
+ if (ctx?.path === "/reset-password") {
2297
+ const token = typeof ctx?.body?.token === "string" ? ctx.body.token : "";
2298
+ if (!token) return void 0;
2299
+ try {
2300
+ const v = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
2301
+ const raw = v?.value;
2302
+ if (!raw) return void 0;
2303
+ if (typeof raw === "string") {
2304
+ const t = raw.trim();
2305
+ if (t.startsWith("{") || t.startsWith('"')) {
2306
+ try {
2307
+ const o = JSON.parse(t);
2308
+ return (typeof o === "string" ? o : o?.userId) ?? void 0;
2309
+ } catch {
2310
+ return t;
2311
+ }
2312
+ }
2313
+ return t;
2314
+ }
2315
+ return raw?.userId ?? void 0;
2316
+ } catch {
2317
+ return void 0;
2318
+ }
2319
+ }
2320
+ return void 0;
2321
+ }
2322
+ /**
2323
+ * ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's
2324
+ * current password or any hash in the bounded history. Reuses better-auth's
2325
+ * native `password.verify` (passed in) rather than re-hashing. Returns the
2326
+ * current hash (for the after-hook to append) when the candidate is fresh, or
2327
+ * undefined when the feature is off / nothing to compare.
2328
+ */
2329
+ async assertPasswordNotReused(userId, candidate, verify) {
2330
+ const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2331
+ if (count <= 0 || typeof verify !== "function") return void 0;
2332
+ const engine = this.getDataEngine();
2333
+ if (!engine) return void 0;
2334
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2335
+ let account;
2336
+ try {
2337
+ account = await engine.findOne("sys_account", {
2338
+ where: { user_id: userId, provider_id: "credential" },
2339
+ fields: ["id", "password", "previous_password_hashes"],
2340
+ context: SYSTEM_CTX
2341
+ });
2342
+ } catch {
2343
+ return void 0;
2344
+ }
2345
+ if (!account?.id) return void 0;
2346
+ const currentHash = typeof account.password === "string" ? account.password : "";
2347
+ const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean);
2348
+ for (const h of compareList) {
2349
+ let match = false;
2350
+ try {
2351
+ match = await verify({ password: candidate, hash: h });
2352
+ } catch {
2353
+ match = false;
2354
+ }
2355
+ if (match) {
2356
+ const { APIError } = await import("better-auth/api");
2357
+ throw new APIError("BAD_REQUEST", {
2358
+ message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`,
2359
+ code: "PASSWORD_REUSE"
2360
+ });
2361
+ }
2362
+ }
2363
+ return currentHash;
2364
+ }
2365
+ /**
2366
+ * ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a
2367
+ * successful change/reset. Best-effort; never throws.
2368
+ */
2369
+ async recordPasswordHistory(userId, oldHash) {
2370
+ const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2371
+ if (count <= 0 || !oldHash) return;
2372
+ const engine = this.getDataEngine();
2373
+ if (!engine) return;
2374
+ try {
2375
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2376
+ const account = await engine.findOne("sys_account", {
2377
+ where: { user_id: userId, provider_id: "credential" },
2378
+ fields: ["id", "previous_password_hashes"],
2379
+ context: SYSTEM_CTX
2380
+ });
2381
+ if (!account?.id) return;
2382
+ const prev = this.parseHashes(account.previous_password_hashes);
2383
+ const next = [oldHash, ...prev.filter((h) => h !== oldHash)].slice(0, count);
2384
+ await engine.update(
2385
+ "sys_account",
2386
+ { id: account.id, previous_password_hashes: JSON.stringify(next) },
2387
+ { context: SYSTEM_CTX }
2388
+ );
2389
+ } catch {
2390
+ }
2391
+ }
2392
+ /**
2393
+ * ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
2394
+ * out (brute-force protection). No-op when lockout is disabled
2395
+ * (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a
2396
+ * lookup error: an infra hiccup must never block every login.
2397
+ */
2398
+ async assertAccountNotLocked(email) {
2399
+ const threshold = Number(this.config.lockoutThreshold) || 0;
2400
+ if (threshold <= 0) return;
2401
+ const engine = this.getDataEngine();
2402
+ if (!engine) return;
2403
+ let locked = false;
2404
+ try {
2405
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2406
+ const u = await engine.findOne("sys_user", {
2407
+ where: { email },
2408
+ fields: ["id", "locked_until"],
2409
+ context: SYSTEM_CTX
2410
+ });
2411
+ const lu = u?.locked_until;
2412
+ locked = !!(lu && new Date(lu).getTime() > Date.now());
2413
+ } catch {
2414
+ return;
2415
+ }
2416
+ if (locked) {
2417
+ const { APIError } = await import("better-auth/api");
2418
+ throw new APIError("FORBIDDEN", {
2419
+ message: "This account is temporarily locked after too many failed sign-in attempts. Try again later or ask an administrator to unlock it.",
2420
+ code: "ACCOUNT_LOCKED"
2421
+ });
2422
+ }
2423
+ }
2424
+ /**
2425
+ * ADR-0069 D2 — record a sign-in outcome for lockout accounting. On failure
2426
+ * increments `failed_login_count` and, once it reaches `lockoutThreshold`,
2427
+ * stamps `locked_until = now + lockoutDurationMinutes`. On success resets
2428
+ * both (only writing when there is something to clear, to avoid a no-op
2429
+ * history row on every login). No-op when lockout is disabled. Never throws —
2430
+ * a counter write must not turn a valid login into an error.
2431
+ */
2432
+ async recordSignInOutcome(email, success) {
2433
+ const threshold = Number(this.config.lockoutThreshold) || 0;
2434
+ if (threshold <= 0) return;
2435
+ const engine = this.getDataEngine();
2436
+ if (!engine) return;
2437
+ try {
2438
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2439
+ const u = await engine.findOne("sys_user", {
2440
+ where: { email },
2441
+ fields: ["id", "failed_login_count", "locked_until"],
2442
+ context: SYSTEM_CTX
2443
+ });
2444
+ if (!u?.id) return;
2445
+ if (success) {
2446
+ if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) {
2447
+ await engine.update(
2448
+ "sys_user",
2449
+ { id: u.id, failed_login_count: 0, locked_until: null },
2450
+ { context: SYSTEM_CTX }
2451
+ );
2452
+ }
2453
+ return;
2454
+ }
2455
+ const next = (Number(u.failed_login_count) || 0) + 1;
2456
+ const patch = { id: u.id, failed_login_count: next };
2457
+ if (next >= threshold) {
2458
+ const mins = Number(this.config.lockoutDurationMinutes) || 15;
2459
+ patch.locked_until = new Date(Date.now() + mins * 6e4);
2460
+ }
2461
+ await engine.update("sys_user", patch, { context: SYSTEM_CTX });
2462
+ } catch {
2463
+ }
2464
+ }
2465
+ /**
2466
+ * ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
2467
+ * Resets `failed_login_count` and `locked_until`. Returns false when no data
2468
+ * engine is wired or the user does not exist.
2469
+ */
2470
+ async unlockUser(userId) {
2471
+ const engine = this.getDataEngine();
2472
+ if (!engine || !userId) return false;
2473
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2474
+ const u = await engine.findOne("sys_user", {
2475
+ where: { id: userId },
2476
+ fields: ["id"],
2477
+ context: SYSTEM_CTX
2478
+ });
2479
+ if (!u?.id) return false;
2480
+ await engine.update(
2481
+ "sys_user",
2482
+ { id: userId, failed_login_count: 0, locked_until: null },
2483
+ { context: SYSTEM_CTX }
2484
+ );
2485
+ return true;
2486
+ }
2487
+ /**
2488
+ * ADR-0069 D4 — idle / absolute session enforcement, run per request from
2489
+ * `customSession`. No-op when both are off. Revokes (expires in place +
2490
+ * stamps revoked_at/revoke_reason) when a limit is exceeded so better-auth
2491
+ * returns no session on the NEXT request; otherwise touches `last_activity_at`
2492
+ * (throttled to once a minute). Best-effort — never throws.
2493
+ */
2494
+ async enforceSessionControls(sessionId, createdAtHint) {
2495
+ const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0);
2496
+ const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0);
2497
+ if (idleMin <= 0 && absHrs <= 0) return;
2498
+ const engine = this.getDataEngine();
2499
+ if (!engine || !sessionId) return;
2500
+ try {
2501
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2502
+ const srow = await engine.findOne("sys_session", {
2503
+ where: { id: sessionId },
2504
+ fields: ["id", "created_at", "last_activity_at", "revoked_at"],
2505
+ context: SYSTEM_CTX
2506
+ });
2507
+ if (!srow?.id || srow.revoked_at) return;
2508
+ const now = Date.now();
2509
+ let reason;
2510
+ if (absHrs > 0) {
2511
+ const created = srow.created_at ?? createdAtHint;
2512
+ if (created && now - new Date(created).getTime() > absHrs * 36e5) reason = "absolute_max";
2513
+ }
2514
+ if (!reason && idleMin > 0) {
2515
+ const last = srow.last_activity_at ?? srow.created_at ?? createdAtHint;
2516
+ if (last && now - new Date(last).getTime() > idleMin * 6e4) reason = "idle_timeout";
2517
+ }
2518
+ if (reason) {
2519
+ await engine.update(
2520
+ "sys_session",
2521
+ { id: sessionId, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: reason },
2522
+ { context: SYSTEM_CTX }
2523
+ ).catch(() => void 0);
2524
+ return;
2525
+ }
2526
+ if (idleMin > 0) {
2527
+ const la = srow.last_activity_at ? new Date(srow.last_activity_at).getTime() : 0;
2528
+ if (now - la > 6e4) {
2529
+ await engine.update("sys_session", { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX }).catch(() => void 0);
2530
+ }
2531
+ }
2532
+ } catch {
2533
+ }
2534
+ }
2535
+ /**
2536
+ * ADR-0069 D4 — concurrent-session cap, run from the sign-in after-hook.
2537
+ * Keeps the newest `maxConcurrentSessions` live sessions for the user and
2538
+ * revokes the rest (oldest first). No-op when off. Best-effort.
2539
+ */
2540
+ async enforceConcurrentCap(userId) {
2541
+ const cap = Math.floor(Number(this.config.maxConcurrentSessions) || 0);
2542
+ if (cap <= 0 || !userId) return;
2543
+ const engine = this.getDataEngine();
2544
+ if (!engine) return;
2545
+ try {
2546
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2547
+ const rows = await engine.find("sys_session", {
2548
+ where: { user_id: userId },
2549
+ fields: ["id", "created_at", "expires_at", "revoked_at"],
2550
+ limit: 200,
2551
+ context: SYSTEM_CTX
2552
+ });
2553
+ const now = Date.now();
2554
+ 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());
2555
+ for (const sn of live.slice(cap)) {
2556
+ await engine.update(
2557
+ "sys_session",
2558
+ { id: sn.id, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: "concurrent_cap" },
2559
+ { context: SYSTEM_CTX }
2560
+ ).catch(() => void 0);
2561
+ }
2562
+ } catch {
2563
+ }
2564
+ }
2565
+ /**
2566
+ * ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no
2567
+ * ranges are configured, OR when the IP can't be determined (fail-open so a
2568
+ * misconfigured proxy never locks everyone out — an admin enabling this must
2569
+ * ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6.
2570
+ */
2571
+ isClientIpAllowed(ip) {
2572
+ const ranges = this.config.allowedIpRanges;
2573
+ if (!ranges || ranges.length === 0) return true;
2574
+ if (!ip) return true;
2575
+ return ranges.some((r) => ipMatchesRange(ip, r));
2576
+ }
1457
2577
  /**
1458
2578
  * Returns the data engine wired into this auth manager. Used by route
1459
2579
  * handlers (e.g. bootstrap-status) that need to query identity tables
@@ -1495,6 +2615,283 @@ function mapSetPasswordError(error) {
1495
2615
  return { status, body: { success: false, error: { code, message } } };
1496
2616
  }
1497
2617
 
2618
+ // src/register-sso-provider.ts
2619
+ async function resolveActiveOrganizationId(handle, registerUrl, headers) {
2620
+ try {
2621
+ const sessionUrl = registerUrl.replace(/\/sso\/register$/, "/get-session");
2622
+ if (sessionUrl === registerUrl) return void 0;
2623
+ const h = new Headers({ accept: "application/json" });
2624
+ const cookie = headers.get("cookie");
2625
+ if (cookie) h.set("cookie", cookie);
2626
+ const authz = headers.get("authorization");
2627
+ if (authz) h.set("authorization", authz);
2628
+ const resp = await handle(new Request(sessionUrl, { method: "GET", headers: h }));
2629
+ if (!resp.ok) return void 0;
2630
+ const data = await resp.json().catch(() => null);
2631
+ const org = data?.session?.activeOrganizationId ?? data?.activeOrganizationId;
2632
+ return typeof org === "string" && org.length > 0 ? org : void 0;
2633
+ } catch {
2634
+ return void 0;
2635
+ }
2636
+ }
2637
+ async function runRegisterSsoProviderFromForm(handle, request) {
2638
+ let body;
2639
+ try {
2640
+ body = await request.json();
2641
+ } catch {
2642
+ body = {};
2643
+ }
2644
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2645
+ const providerId = str(body?.providerId);
2646
+ const issuer = str(body?.issuer);
2647
+ const domain = str(body?.domain);
2648
+ const clientId = str(body?.clientId);
2649
+ const clientSecret = str(body?.clientSecret);
2650
+ const discoveryEndpoint = str(body?.discoveryEndpoint);
2651
+ const scopesRaw = str(body?.scopes);
2652
+ const missing = [
2653
+ ["providerId", providerId],
2654
+ ["issuer", issuer],
2655
+ ["domain", domain],
2656
+ ["clientId", clientId],
2657
+ ["clientSecret", clientSecret]
2658
+ ].filter(([, v]) => !v).map(([k]) => k);
2659
+ if (missing.length) {
2660
+ return {
2661
+ status: 400,
2662
+ body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } }
2663
+ };
2664
+ }
2665
+ const oidcConfig = { clientId, clientSecret };
2666
+ if (discoveryEndpoint) oidcConfig.discoveryEndpoint = discoveryEndpoint;
2667
+ oidcConfig.scopes = scopesRaw ? scopesRaw.split(/[\s,]+/).filter(Boolean) : ["openid", "email", "profile"];
2668
+ oidcConfig.mapping = {
2669
+ id: str(body?.mapId) || "sub",
2670
+ email: str(body?.mapEmail) || "email",
2671
+ name: str(body?.mapName) || "name"
2672
+ };
2673
+ let innerUrl;
2674
+ let origin;
2675
+ try {
2676
+ const url = new URL(request.url);
2677
+ origin = url.origin;
2678
+ innerUrl = `${origin}${url.pathname.replace(/\/admin\/sso\/register$/, "/sso/register")}`;
2679
+ } catch {
2680
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2681
+ }
2682
+ const headers = new Headers({ "content-type": "application/json" });
2683
+ const cookie = request.headers.get("cookie");
2684
+ if (cookie) headers.set("cookie", cookie);
2685
+ const authz = request.headers.get("authorization");
2686
+ if (authz) headers.set("authorization", authz);
2687
+ headers.set("origin", request.headers.get("origin") || origin);
2688
+ const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
2689
+ const innerReq = new Request(innerUrl, {
2690
+ method: "POST",
2691
+ headers,
2692
+ body: JSON.stringify({ providerId, issuer, domain, oidcConfig, ...organizationId ? { organizationId } : {} })
2693
+ });
2694
+ const resp = await handle(innerReq);
2695
+ let parsed = {};
2696
+ try {
2697
+ const t = await resp.text();
2698
+ parsed = t ? JSON.parse(t) : {};
2699
+ } catch {
2700
+ parsed = {};
2701
+ }
2702
+ if (!resp.ok) {
2703
+ return {
2704
+ status: resp.status,
2705
+ body: { success: false, error: { code: "sso_register_failed", message: parsed?.message || "SSO provider registration failed" } }
2706
+ };
2707
+ }
2708
+ return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } };
2709
+ }
2710
+ async function runRegisterSamlProviderFromForm(handle, request) {
2711
+ let body;
2712
+ try {
2713
+ body = await request.json();
2714
+ } catch {
2715
+ body = {};
2716
+ }
2717
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2718
+ const providerId = str(body?.providerId);
2719
+ const issuer = str(body?.issuer);
2720
+ const domain = str(body?.domain);
2721
+ const entryPoint = str(body?.entryPoint);
2722
+ const cert = str(body?.cert);
2723
+ const identifierFormat = str(body?.identifierFormat);
2724
+ const missing = [
2725
+ ["providerId", providerId],
2726
+ ["issuer", issuer],
2727
+ ["domain", domain],
2728
+ ["entryPoint", entryPoint],
2729
+ ["cert", cert]
2730
+ ].filter(([, v]) => !v).map(([k]) => k);
2731
+ if (missing.length) {
2732
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } } };
2733
+ }
2734
+ let origin;
2735
+ let prefix;
2736
+ let innerUrl;
2737
+ try {
2738
+ const url = new URL(request.url);
2739
+ origin = url.origin;
2740
+ prefix = url.pathname.replace(/\/admin\/sso\/register-saml$/, "");
2741
+ innerUrl = `${origin}${prefix}/sso/register`;
2742
+ } catch {
2743
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2744
+ }
2745
+ const acsUrl = `${origin}${prefix}/sso/saml2/sp/acs/${encodeURIComponent(providerId)}`;
2746
+ const spMetadataUrl = `${origin}${prefix}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}`;
2747
+ const samlConfig = {
2748
+ entryPoint,
2749
+ cert,
2750
+ callbackUrl: acsUrl,
2751
+ // better-auth requires an SP descriptor (its inner fields are optional). Use
2752
+ // the SP metadata URL as our EntityID — the value the IdP keys this SP on.
2753
+ spMetadata: { entityID: spMetadataUrl }
2754
+ };
2755
+ if (identifierFormat) samlConfig.identifierFormat = identifierFormat;
2756
+ const headers = new Headers({ "content-type": "application/json" });
2757
+ const cookie = request.headers.get("cookie");
2758
+ if (cookie) headers.set("cookie", cookie);
2759
+ const authz = request.headers.get("authorization");
2760
+ if (authz) headers.set("authorization", authz);
2761
+ headers.set("origin", request.headers.get("origin") || origin);
2762
+ const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
2763
+ const innerReq = new Request(innerUrl, {
2764
+ method: "POST",
2765
+ headers,
2766
+ body: JSON.stringify({ providerId, issuer, domain, samlConfig, ...organizationId ? { organizationId } : {} })
2767
+ });
2768
+ const resp = await handle(innerReq);
2769
+ let parsed = {};
2770
+ try {
2771
+ const t = await resp.text();
2772
+ parsed = t ? JSON.parse(t) : {};
2773
+ } catch {
2774
+ parsed = {};
2775
+ }
2776
+ if (!resp.ok) {
2777
+ return { status: resp.status, body: { success: false, error: { code: "saml_register_failed", message: parsed?.message || "SAML provider registration failed" } } };
2778
+ }
2779
+ return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } };
2780
+ }
2781
+ var SSO_DOMAIN_TOKEN_PREFIX = "better-auth-token";
2782
+ function bareHostname(domain) {
2783
+ let d = domain.trim();
2784
+ if (!d) return d;
2785
+ const schemeIdx = d.indexOf("://");
2786
+ if (schemeIdx !== -1) {
2787
+ try {
2788
+ return new URL(d).hostname;
2789
+ } catch {
2790
+ d = d.slice(schemeIdx + 3);
2791
+ }
2792
+ }
2793
+ for (const sep of ["/", ":", "?", "#"]) {
2794
+ const i = d.indexOf(sep);
2795
+ if (i !== -1) d = d.slice(0, i);
2796
+ }
2797
+ return d;
2798
+ }
2799
+ function rewriteSsoAdminUrl(request, fromSuffix, toPath) {
2800
+ try {
2801
+ const url = new URL(request.url);
2802
+ return { origin: url.origin, innerUrl: `${url.origin}${url.pathname.replace(fromSuffix, toPath)}` };
2803
+ } catch {
2804
+ return null;
2805
+ }
2806
+ }
2807
+ function forwardAuthHeaders(request, origin) {
2808
+ const headers = new Headers({ "content-type": "application/json" });
2809
+ const cookie = request.headers.get("cookie");
2810
+ if (cookie) headers.set("cookie", cookie);
2811
+ const authz = request.headers.get("authorization");
2812
+ if (authz) headers.set("authorization", authz);
2813
+ headers.set("origin", request.headers.get("origin") || origin);
2814
+ return headers;
2815
+ }
2816
+ async function runRequestDomainVerification(handle, request) {
2817
+ let body;
2818
+ try {
2819
+ body = await request.json();
2820
+ } catch {
2821
+ body = {};
2822
+ }
2823
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2824
+ const providerId = str(body?.providerId);
2825
+ const domain = bareHostname(str(body?.domain));
2826
+ if (!providerId) {
2827
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
2828
+ }
2829
+ const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, "/sso/request-domain-verification");
2830
+ if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2831
+ const headers = forwardAuthHeaders(request, rw.origin);
2832
+ const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
2833
+ let parsed = {};
2834
+ try {
2835
+ const t = await resp.text();
2836
+ parsed = t ? JSON.parse(t) : {};
2837
+ } catch {
2838
+ parsed = {};
2839
+ }
2840
+ if (!resp.ok) {
2841
+ if (resp.status === 404 && !parsed?.code) {
2842
+ 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)." } } };
2843
+ }
2844
+ return { status: resp.status, body: { success: false, error: { code: parsed?.code || "request_domain_verification_failed", message: parsed?.message || "Failed to request domain verification" } } };
2845
+ }
2846
+ const token = str(parsed?.domainVerificationToken);
2847
+ const label = `_${SSO_DOMAIN_TOKEN_PREFIX}-${providerId}`;
2848
+ const dnsRecordName = domain ? `${label}.${domain}` : label;
2849
+ const dnsRecordValue = `${label}=${token}`;
2850
+ return {
2851
+ status: 200,
2852
+ body: {
2853
+ success: true,
2854
+ data: { providerId, domain, token, dnsRecordType: "TXT", dnsRecordName, dnsRecordValue }
2855
+ }
2856
+ };
2857
+ }
2858
+ async function runVerifyDomain(handle, request) {
2859
+ let body;
2860
+ try {
2861
+ body = await request.json();
2862
+ } catch {
2863
+ body = {};
2864
+ }
2865
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2866
+ const providerId = str(body?.providerId);
2867
+ if (!providerId) {
2868
+ return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
2869
+ }
2870
+ const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, "/sso/verify-domain");
2871
+ if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
2872
+ const headers = forwardAuthHeaders(request, rw.origin);
2873
+ const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
2874
+ let parsed = {};
2875
+ try {
2876
+ const t = await resp.text();
2877
+ parsed = t ? JSON.parse(t) : {};
2878
+ } catch {
2879
+ parsed = {};
2880
+ }
2881
+ if (resp.ok) {
2882
+ return { status: 200, body: { success: true, data: { providerId, verified: true, message: "Domain ownership verified \u2014 this provider can now sign users in." } } };
2883
+ }
2884
+ let message = parsed?.message || "Domain verification failed";
2885
+ if (resp.status === 404 && !parsed?.code) {
2886
+ message = "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).";
2887
+ } else if (parsed?.code === "NO_PENDING_VERIFICATION") {
2888
+ message = "No pending verification \u2014 click \u201CRequest Domain Verification\u201D first to get the DNS record.";
2889
+ } else if (parsed?.code === "DOMAIN_VERIFICATION_FAILED") {
2890
+ message = "DNS TXT record not found yet. Add the record shown when you requested verification, allow time for DNS to propagate, then retry.";
2891
+ }
2892
+ return { status: resp.status, body: { success: false, error: { code: parsed?.code || "verify_domain_failed", message } } };
2893
+ }
2894
+
1498
2895
  // src/manifest.ts
1499
2896
  var import_identity = require("@objectstack/platform-objects/identity");
1500
2897
  var AUTH_PLUGIN_ID = "com.objectstack.plugin-auth";
@@ -1517,7 +2914,9 @@ var authIdentityObjects = [
1517
2914
  import_identity.SysOauthRefreshToken,
1518
2915
  import_identity.SysOauthConsent,
1519
2916
  import_identity.SysJwks,
1520
- import_identity.SysDeviceCode
2917
+ import_identity.SysDeviceCode,
2918
+ import_identity.SysSsoProvider,
2919
+ import_identity.SysScimProvider
1521
2920
  ];
1522
2921
  var authPluginManifestHeader = {
1523
2922
  id: AUTH_PLUGIN_ID,
@@ -1610,7 +3009,33 @@ var AuthPlugin = class {
1610
3009
  // source of truth.
1611
3010
  dashboards: [import_apps.SystemOverviewDashboard],
1612
3011
  // ADR-0021 — datasets backing the System Overview dashboard's widgets.
1613
- datasets: import_apps.SystemOverviewDatasets
3012
+ datasets: import_apps.SystemOverviewDatasets,
3013
+ // ADR-0024 / cloud#551 — surface "SSO Providers" (sys_sso_provider) in the
3014
+ // Setup app's Access Control group, but ONLY when the external-IdP RP is
3015
+ // wired (self-host `OS_SSO_ENABLED`, or the cloud per-env `planAllowsSso`
3016
+ // arriving via `plugins.sso`). Without the gate the entry would render an
3017
+ // empty list + a "Register" button whose endpoint 404s when SSO is off.
3018
+ // Owning-plugin-contributes pattern (ADR-0029 K2), mirroring plugin-security.
3019
+ ...this.authManager.isSsoWired() ? {
3020
+ navigationContributions: [
3021
+ {
3022
+ app: "setup",
3023
+ group: "group_access_control",
3024
+ // After Roles/Permission-Sets (100) and Sharing (200), near API Keys (300).
3025
+ priority: 250,
3026
+ items: [
3027
+ {
3028
+ id: "nav_sso_providers",
3029
+ type: "object",
3030
+ label: "SSO Providers",
3031
+ objectName: "sys_sso_provider",
3032
+ icon: "log-in",
3033
+ requiredPermissions: ["manage_platform_settings"]
3034
+ }
3035
+ ]
3036
+ }
3037
+ ]
3038
+ } : {}
1614
3039
  });
1615
3040
  ctx.logger.info("Auth Plugin initialized successfully");
1616
3041
  }
@@ -1623,14 +3048,24 @@ var AuthPlugin = class {
1623
3048
  ctx.hook("kernel:ready", async () => {
1624
3049
  if (this.authManager) {
1625
3050
  await this.bindAuthSettings(ctx);
3051
+ let emailSvc;
1626
3052
  try {
1627
- const emailSvc = ctx.getService("email");
1628
- if (emailSvc) {
1629
- this.authManager.setEmailService(emailSvc);
1630
- ctx.logger.info("Auth: email service wired (transactional mail enabled)");
1631
- }
3053
+ emailSvc = ctx.getService("email");
1632
3054
  } catch {
1633
- ctx.logger.info("Auth: no email service registered \u2014 auth callbacks will log instead of sending");
3055
+ emailSvc = void 0;
3056
+ }
3057
+ if (emailSvc) {
3058
+ this.authManager.setEmailService(emailSvc);
3059
+ ctx.logger.info("Auth: email service wired (transactional mail enabled)");
3060
+ } else {
3061
+ const requiresEmail = !!this.authManager.getPublicConfig?.()?.emailPassword?.requireEmailVerification;
3062
+ if (requiresEmail) {
3063
+ ctx.logger.error(
3064
+ "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)."
3065
+ );
3066
+ } else {
3067
+ ctx.logger.info("Auth: no email service registered \u2014 transactional mail disabled");
3068
+ }
1634
3069
  }
1635
3070
  try {
1636
3071
  const settings = ctx.getService("settings");
@@ -1693,6 +3128,38 @@ var AuthPlugin = class {
1693
3128
  ctx.hook("kernel:ready", async () => {
1694
3129
  await this.maybeSeedDevAdmin(ctx);
1695
3130
  });
3131
+ ctx.hook("kernel:ready", async () => {
3132
+ try {
3133
+ const engine = ctx.getService("objectql");
3134
+ if (!engine || typeof engine.registerHook !== "function") return;
3135
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
3136
+ engine.registerHook("afterInsert", async (hookCtx) => {
3137
+ try {
3138
+ if (hookCtx?.object !== "sys_account") return;
3139
+ const acct = hookCtx.result ?? {};
3140
+ const providerId = acct.provider_id ?? acct.providerId;
3141
+ const userId = acct.user_id ?? acct.userId;
3142
+ if (!userId || !providerId || providerId === "credential") return;
3143
+ const credCount = await engine.count("sys_account", {
3144
+ where: { user_id: userId, provider_id: "credential" },
3145
+ context: SYSTEM_CTX
3146
+ });
3147
+ if (typeof credCount === "number" && credCount > 0) return;
3148
+ const u = await engine.findOne("sys_user", {
3149
+ where: { id: userId },
3150
+ fields: ["id", "source"],
3151
+ context: SYSTEM_CTX
3152
+ });
3153
+ if (u && u.source !== "idp_provisioned") {
3154
+ await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
3155
+ }
3156
+ } catch {
3157
+ }
3158
+ }, { packageId: "com.objectstack.plugin-auth" });
3159
+ ctx.logger.info("Identity-source afterInsert stamp registered on sys_account (SCIM-safe)");
3160
+ } catch {
3161
+ }
3162
+ });
1696
3163
  try {
1697
3164
  const ql = ctx.getService("objectql");
1698
3165
  if (ql && typeof ql.registerMiddleware === "function") {
@@ -1776,6 +3243,41 @@ var AuthPlugin = class {
1776
3243
  if (Object.keys(emailAndPassword).length > 0) {
1777
3244
  patch.emailAndPassword = emailAndPassword;
1778
3245
  }
3246
+ if (isExplicit("password_reject_breached")) {
3247
+ patch.plugins = {
3248
+ ...patch.plugins ?? {},
3249
+ passwordRejectBreached: asBoolean(values.password_reject_breached, false)
3250
+ };
3251
+ }
3252
+ if (isExplicit("password_require_complexity")) {
3253
+ patch.passwordRequireComplexity = asBoolean(values.password_require_complexity, false);
3254
+ }
3255
+ if (isExplicit("password_min_classes")) {
3256
+ const n = asPositiveInt(values.password_min_classes);
3257
+ if (n !== void 0) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
3258
+ }
3259
+ if (isExplicit("password_history_count")) {
3260
+ const n = Math.floor(Number(values.password_history_count));
3261
+ if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
3262
+ }
3263
+ if (isExplicit("password_expiry_days")) {
3264
+ const n = Math.floor(Number(values.password_expiry_days));
3265
+ if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n);
3266
+ }
3267
+ if (isExplicit("mfa_required")) {
3268
+ const on = asBoolean(values.mfa_required, false);
3269
+ patch.mfaRequired = on;
3270
+ if (on) {
3271
+ patch.plugins = {
3272
+ ...patch.plugins ?? {},
3273
+ twoFactor: true
3274
+ };
3275
+ }
3276
+ }
3277
+ if (isExplicit("mfa_grace_period_days")) {
3278
+ const n = Math.floor(Number(values.mfa_grace_period_days));
3279
+ if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n);
3280
+ }
1779
3281
  const session = {};
1780
3282
  if (isExplicit("session_expiry_days")) {
1781
3283
  const d = asPositiveInt(values.session_expiry_days);
@@ -1788,6 +3290,53 @@ var AuthPlugin = class {
1788
3290
  if (Object.keys(session).length > 0) {
1789
3291
  patch.session = session;
1790
3292
  }
3293
+ const asNonNeg = (v) => {
3294
+ const n = Math.floor(Number(v));
3295
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
3296
+ };
3297
+ if (isExplicit("session_idle_timeout_minutes")) {
3298
+ const n = asNonNeg(values.session_idle_timeout_minutes);
3299
+ if (n !== void 0) patch.sessionIdleTimeoutMinutes = n;
3300
+ }
3301
+ if (isExplicit("session_absolute_max_hours")) {
3302
+ const n = asNonNeg(values.session_absolute_max_hours);
3303
+ if (n !== void 0) patch.sessionAbsoluteMaxHours = n;
3304
+ }
3305
+ if (isExplicit("max_concurrent_sessions_per_user")) {
3306
+ const n = asNonNeg(values.max_concurrent_sessions_per_user);
3307
+ if (n !== void 0) patch.maxConcurrentSessions = n;
3308
+ }
3309
+ if (isExplicit("allowed_ip_ranges")) {
3310
+ const raw = asTrimmedString(values.allowed_ip_ranges) ?? "";
3311
+ patch.allowedIpRanges = raw.split(/[\n,]+/).map((r) => r.trim()).filter(Boolean);
3312
+ }
3313
+ const asNonNegativeInt = (value) => {
3314
+ const n = Math.floor(Number(value));
3315
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
3316
+ };
3317
+ if (isExplicit("lockout_threshold")) {
3318
+ const n = asNonNegativeInt(values.lockout_threshold);
3319
+ if (n !== void 0) patch.lockoutThreshold = n;
3320
+ }
3321
+ if (isExplicit("lockout_duration_minutes")) {
3322
+ const n = asPositiveInt(values.lockout_duration_minutes);
3323
+ if (n !== void 0) patch.lockoutDurationMinutes = n;
3324
+ }
3325
+ if (isExplicit("rate_limit_max") || isExplicit("rate_limit_window_seconds")) {
3326
+ const max = asPositiveInt(values.rate_limit_max) ?? 10;
3327
+ const window = asPositiveInt(values.rate_limit_window_seconds) ?? 60;
3328
+ patch.rateLimit = {
3329
+ enabled: true,
3330
+ window,
3331
+ max,
3332
+ customRules: {
3333
+ "/sign-in/email": { window, max },
3334
+ "/sign-up/email": { window, max },
3335
+ "/request-password-reset": { window, max },
3336
+ "/reset-password": { window, max }
3337
+ }
3338
+ };
3339
+ }
1791
3340
  if (isExplicit("google_enabled") || isExplicit("google_client_id") || isExplicit("google_client_secret")) {
1792
3341
  const socialProviders = {
1793
3342
  ...this.configuredSocialProviders ?? {}
@@ -1909,9 +3458,27 @@ var AuthPlugin = class {
1909
3458
  );
1910
3459
  }
1911
3460
  const rawApp = httpServer.getRawApp();
1912
- rawApp.get(`${basePath}/config`, (c) => {
3461
+ if (typeof rawApp.use === "function") rawApp.use(`${basePath}/*`, async (c, next) => {
3462
+ const mgr = this.authManager;
3463
+ if (!mgr || typeof mgr.isClientIpAllowed !== "function") return next();
3464
+ const path = c.req.path || "";
3465
+ if (path.endsWith("/config") || path.endsWith("/bootstrap-status")) return next();
3466
+ const fwd = c.req.header("x-forwarded-for");
3467
+ const ip = typeof fwd === "string" && fwd.split(",")[0].trim() || c.req.header("cf-connecting-ip") || c.req.header("x-real-ip") || void 0;
3468
+ if (!mgr.isClientIpAllowed(ip)) {
3469
+ return c.json(
3470
+ { success: false, error: { code: "IP_NOT_ALLOWED", message: "Sign-in is not allowed from your network." } },
3471
+ 403
3472
+ );
3473
+ }
3474
+ return next();
3475
+ });
3476
+ rawApp.get(`${basePath}/config`, async (c) => {
1913
3477
  try {
1914
3478
  const config = this.authManager.getPublicConfig();
3479
+ if (config.features?.sso) {
3480
+ config.features.sso = await this.authManager.isSsoUsable();
3481
+ }
1915
3482
  return c.json({ success: true, data: config });
1916
3483
  } catch (error) {
1917
3484
  const err = error instanceof Error ? error : new Error(String(error));
@@ -1997,6 +3564,91 @@ var AuthPlugin = class {
1997
3564
  return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
1998
3565
  }
1999
3566
  });
3567
+ rawApp.post(`${basePath}/admin/sso/register`, async (c) => {
3568
+ try {
3569
+ const { status, body } = await runRegisterSsoProviderFromForm(
3570
+ (req) => this.authManager.handleRequest(req),
3571
+ c.req.raw
3572
+ );
3573
+ return c.json(body, status);
3574
+ } catch (error) {
3575
+ const err = error instanceof Error ? error : new Error(String(error));
3576
+ ctx.logger.error("[AuthPlugin] sso/register bridge failed", err);
3577
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3578
+ }
3579
+ });
3580
+ rawApp.post(`${basePath}/admin/unlock-user`, async (c) => {
3581
+ try {
3582
+ let body = {};
3583
+ try {
3584
+ body = await c.req.json();
3585
+ } catch {
3586
+ body = {};
3587
+ }
3588
+ const userId = body?.userId ?? body?.user_id;
3589
+ if (typeof userId !== "string" || userId.length === 0) {
3590
+ return c.json({ success: false, error: { code: "invalid_request", message: "userId is required" } }, 400);
3591
+ }
3592
+ const authApi = await this.authManager.getApi();
3593
+ const session = await authApi.getSession({ headers: c.req.raw.headers });
3594
+ if (!session?.user?.id) {
3595
+ return c.json({ success: false, error: { code: "unauthorized", message: "Sign in first" } }, 401);
3596
+ }
3597
+ const u = session.user;
3598
+ const isAdmin = u?.isPlatformAdmin === true || Array.isArray(u?.roles) && u.roles.includes("platform_admin") || u?.role === "admin";
3599
+ if (!isAdmin) {
3600
+ return c.json({ success: false, error: { code: "forbidden", message: "Admin role required" } }, 403);
3601
+ }
3602
+ const ok = await this.authManager.unlockUser(userId);
3603
+ if (!ok) {
3604
+ return c.json({ success: false, error: { code: "not_found", message: "User not found or data engine unavailable" } }, 404);
3605
+ }
3606
+ return c.json({ success: true, data: { userId } });
3607
+ } catch (error) {
3608
+ const err = error instanceof Error ? error : new Error(String(error));
3609
+ ctx.logger.error("[AuthPlugin] unlock-user failed", err);
3610
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3611
+ }
3612
+ });
3613
+ rawApp.post(`${basePath}/admin/sso/register-saml`, async (c) => {
3614
+ try {
3615
+ const { status, body } = await runRegisterSamlProviderFromForm(
3616
+ (req) => this.authManager.handleRequest(req),
3617
+ c.req.raw
3618
+ );
3619
+ return c.json(body, status);
3620
+ } catch (error) {
3621
+ const err = error instanceof Error ? error : new Error(String(error));
3622
+ ctx.logger.error("[AuthPlugin] sso/register-saml bridge failed", err);
3623
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3624
+ }
3625
+ });
3626
+ rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c) => {
3627
+ try {
3628
+ const { status, body } = await runRequestDomainVerification(
3629
+ (req) => this.authManager.handleRequest(req),
3630
+ c.req.raw
3631
+ );
3632
+ return c.json(body, status);
3633
+ } catch (error) {
3634
+ const err = error instanceof Error ? error : new Error(String(error));
3635
+ ctx.logger.error("[AuthPlugin] sso/request-domain-verification bridge failed", err);
3636
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3637
+ }
3638
+ });
3639
+ rawApp.post(`${basePath}/admin/sso/verify-domain`, async (c) => {
3640
+ try {
3641
+ const { status, body } = await runVerifyDomain(
3642
+ (req) => this.authManager.handleRequest(req),
3643
+ c.req.raw
3644
+ );
3645
+ return c.json(body, status);
3646
+ } catch (error) {
3647
+ const err = error instanceof Error ? error : new Error(String(error));
3648
+ ctx.logger.error("[AuthPlugin] sso/verify-domain bridge failed", err);
3649
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
3650
+ }
3651
+ });
2000
3652
  rawApp.post(`${basePath}/sys-oauth-application/register`, async (c) => {
2001
3653
  try {
2002
3654
  let body = {};
@@ -2143,7 +3795,9 @@ var AuthPlugin = class {
2143
3795
  AUTH_OAUTH_REFRESH_TOKEN_SCHEMA,
2144
3796
  AUTH_ORGANIZATION_SCHEMA,
2145
3797
  AUTH_ORG_SESSION_FIELDS,
3798
+ AUTH_SCIM_PROVIDER_SCHEMA,
2146
3799
  AUTH_SESSION_CONFIG,
3800
+ AUTH_SSO_PROVIDER_SCHEMA,
2147
3801
  AUTH_TEAM_MEMBER_SCHEMA,
2148
3802
  AUTH_TEAM_SCHEMA,
2149
3803
  AUTH_TWO_FACTOR_SCHEMA,
@@ -2161,7 +3815,13 @@ var AuthPlugin = class {
2161
3815
  buildTwoFactorPluginSchema,
2162
3816
  createObjectQLAdapter,
2163
3817
  createObjectQLAdapterFactory,
3818
+ ipMatchesRange,
2164
3819
  resolveProtocolName,
2165
- runSetInitialPassword
3820
+ runRegisterSamlProviderFromForm,
3821
+ runRegisterSsoProviderFromForm,
3822
+ runRequestDomainVerification,
3823
+ runSetInitialPassword,
3824
+ runVerifyDomain,
3825
+ withSystemReadContext
2166
3826
  });
2167
3827
  //# sourceMappingURL=index.js.map