@objectstack/plugin-auth 10.3.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  import { SysOrganizationDetailPage, SysUserDetailPage } from "@objectstack/platform-objects/pages";
8
8
 
9
9
  // src/auth-manager.ts
10
- import { readEnvWithDeprecation } from "@objectstack/types";
10
+ import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from "@objectstack/types";
11
+ import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from "@objectstack/spec";
11
12
 
12
13
  // src/objectql-adapter.ts
13
14
  import { createAdapterFactory } from "better-auth/adapters";
@@ -16,7 +17,16 @@ var AUTH_MODEL_TO_PROTOCOL = {
16
17
  user: SystemObjectName.USER,
17
18
  session: SystemObjectName.SESSION,
18
19
  account: SystemObjectName.ACCOUNT,
19
- verification: SystemObjectName.VERIFICATION
20
+ verification: SystemObjectName.VERIFICATION,
21
+ // Plugin models. `@better-auth/sso` and `@better-auth/scim` both hardcode
22
+ // their model name and accept NO `schema` option (verified vs 1.6.2x — no
23
+ // mergeSchema, runtime never reads options.schema), so the table name is
24
+ // bridged here and `createObjectQLAdapterFactory` (below) auto-maps their
25
+ // camelCase fields to snake_case (oidcConfig→oidc_config, scimToken→
26
+ // scim_token, …) on every CRUD op via resolveProtocolName. Off by default
27
+ // (OS_SSO_ENABLED / OS_SCIM_ENABLED). See ADR-0024 / ADR-0071.
28
+ ssoProvider: "sys_sso_provider",
29
+ scimProvider: "sys_scim_provider"
20
30
  };
21
31
  function resolveProtocolName(model) {
22
32
  return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
@@ -80,7 +90,28 @@ function convertWhere(where) {
80
90
  }
81
91
  return filter;
82
92
  }
83
- function createObjectQLAdapterFactory(dataEngine) {
93
+ function withSystemReadContext(engine) {
94
+ const e = engine;
95
+ const asSystem = (q) => ({ ...q ?? {}, context: { isSystem: true, ...q?.context ?? {} } });
96
+ return {
97
+ insert: (m, d) => e.insert(m, d),
98
+ update: (m, d) => e.update(m, d),
99
+ delete: (m, q) => e.delete(m, q),
100
+ find: (m, q) => e.find(m, asSystem(q)),
101
+ findOne: (m, q) => e.findOne(m, asSystem(q)),
102
+ count: (m, q) => e.count(m, asSystem(q))
103
+ };
104
+ }
105
+ function createObjectQLAdapterFactory(rawDataEngine) {
106
+ const dataEngine = withSystemReadContext(rawDataEngine);
107
+ const camelToSnake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
108
+ const snakeToCamel = (s) => s.replace(/_([a-z])/g, (_m, c) => c.toUpperCase());
109
+ const remapKeys = (obj, fn) => {
110
+ const out = {};
111
+ for (const k of Object.keys(obj)) out[fn(k)] = obj[k];
112
+ return out;
113
+ };
114
+ const remapWhere = (where) => where.map((c) => ({ ...c, field: camelToSnake(c.field) }));
84
115
  return createAdapterFactory({
85
116
  config: {
86
117
  adapterId: "objectql",
@@ -95,62 +126,90 @@ function createObjectQLAdapterFactory(dataEngine) {
95
126
  },
96
127
  adapter: () => ({
97
128
  create: async ({ model, data, select: _select }) => {
98
- const result = await dataEngine.insert(model, data);
99
- return normaliseLegacyDates(model, result);
129
+ const objectName = resolveProtocolName(model);
130
+ const bridged = objectName !== model;
131
+ const result = await dataEngine.insert(objectName, bridged ? remapKeys(data, camelToSnake) : data);
132
+ const norm = normaliseLegacyDates(model, result);
133
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
100
134
  },
101
135
  findOne: async ({ model, where, select, join: _join }) => {
102
- const filter = convertWhere(where);
103
- const result = await dataEngine.findOne(model, { where: filter, fields: select });
104
- return result ? normaliseLegacyDates(model, result) : null;
136
+ const objectName = resolveProtocolName(model);
137
+ const bridged = objectName !== model;
138
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
139
+ const fields = bridged && select ? select.map(camelToSnake) : select;
140
+ const result = await dataEngine.findOne(objectName, { where: filter, fields });
141
+ if (!result) return null;
142
+ const norm = normaliseLegacyDates(model, result);
143
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
105
144
  },
106
145
  findMany: async ({ model, where, limit, offset, sortBy, join: _join }) => {
107
- const filter = where ? convertWhere(where) : {};
108
- const orderBy = sortBy ? [{ field: sortBy.field, order: sortBy.direction }] : void 0;
109
- const results = await dataEngine.find(model, {
146
+ const objectName = resolveProtocolName(model);
147
+ const bridged = objectName !== model;
148
+ const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
149
+ const orderBy = sortBy ? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction }] : void 0;
150
+ const results = await dataEngine.find(objectName, {
110
151
  where: filter,
111
152
  limit: limit || 100,
112
153
  offset,
113
154
  orderBy
114
155
  });
115
- return results.map((r) => normaliseLegacyDates(model, r));
156
+ return results.map((r) => {
157
+ const norm = normaliseLegacyDates(model, r);
158
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
159
+ });
116
160
  },
117
161
  count: async ({ model, where }) => {
118
- const filter = where ? convertWhere(where) : {};
119
- return await dataEngine.count(model, { where: filter });
162
+ const objectName = resolveProtocolName(model);
163
+ const bridged = objectName !== model;
164
+ const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
165
+ return await dataEngine.count(objectName, { where: filter });
120
166
  },
121
167
  update: async ({ model, where, update }) => {
122
- const filter = convertWhere(where);
123
- const record = await dataEngine.findOne(model, { where: filter });
168
+ const objectName = resolveProtocolName(model);
169
+ const bridged = objectName !== model;
170
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
171
+ const record = await dataEngine.findOne(objectName, { where: filter });
124
172
  if (!record) return null;
125
- const result = await dataEngine.update(model, { ...update, id: record.id });
126
- return result ? normaliseLegacyDates(model, result) : null;
173
+ const patch = bridged ? remapKeys(update, camelToSnake) : update;
174
+ const result = await dataEngine.update(objectName, { ...patch, id: record.id });
175
+ if (!result) return null;
176
+ const norm = normaliseLegacyDates(model, result);
177
+ return bridged ? remapKeys(norm, snakeToCamel) : norm;
127
178
  },
128
179
  updateMany: async ({ model, where, update }) => {
129
- const filter = convertWhere(where);
130
- const records = await dataEngine.find(model, { where: filter });
180
+ const objectName = resolveProtocolName(model);
181
+ const bridged = objectName !== model;
182
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
183
+ const records = await dataEngine.find(objectName, { where: filter });
184
+ const patch = bridged ? remapKeys(update, camelToSnake) : update;
131
185
  for (const record of records) {
132
- await dataEngine.update(model, { ...update, id: record.id });
186
+ await dataEngine.update(objectName, { ...patch, id: record.id });
133
187
  }
134
188
  return records.length;
135
189
  },
136
190
  delete: async ({ model, where }) => {
137
- const filter = convertWhere(where);
138
- const record = await dataEngine.findOne(model, { where: filter });
191
+ const objectName = resolveProtocolName(model);
192
+ const bridged = objectName !== model;
193
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
194
+ const record = await dataEngine.findOne(objectName, { where: filter });
139
195
  if (!record) return;
140
- await dataEngine.delete(model, { where: { id: record.id } });
196
+ await dataEngine.delete(objectName, { where: { id: record.id } });
141
197
  },
142
198
  deleteMany: async ({ model, where }) => {
143
- const filter = convertWhere(where);
144
- const records = await dataEngine.find(model, { where: filter });
199
+ const objectName = resolveProtocolName(model);
200
+ const bridged = objectName !== model;
201
+ const filter = convertWhere(bridged ? remapWhere(where) : where);
202
+ const records = await dataEngine.find(objectName, { where: filter });
145
203
  for (const record of records) {
146
- await dataEngine.delete(model, { where: { id: record.id } });
204
+ await dataEngine.delete(objectName, { where: { id: record.id } });
147
205
  }
148
206
  return records.length;
149
207
  }
150
208
  })
151
209
  });
152
210
  }
153
- function createObjectQLAdapter(dataEngine) {
211
+ function createObjectQLAdapter(rawDataEngine) {
212
+ const dataEngine = withSystemReadContext(rawDataEngine);
154
213
  return {
155
214
  create: async ({ model, data, select: _select }) => {
156
215
  const objectName = resolveProtocolName(model);
@@ -456,6 +515,25 @@ function buildOauthProviderPluginSchema() {
456
515
  };
457
516
  }
458
517
  var buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
518
+ var AUTH_SSO_PROVIDER_SCHEMA = {
519
+ modelName: "sys_sso_provider",
520
+ fields: {
521
+ providerId: "provider_id",
522
+ oidcConfig: "oidc_config",
523
+ samlConfig: "saml_config",
524
+ userId: "user_id",
525
+ organizationId: "organization_id"
526
+ }
527
+ };
528
+ var AUTH_SCIM_PROVIDER_SCHEMA = {
529
+ modelName: "sys_scim_provider",
530
+ fields: {
531
+ providerId: "provider_id",
532
+ scimToken: "scim_token",
533
+ organizationId: "organization_id",
534
+ userId: "user_id"
535
+ }
536
+ };
459
537
  function buildDeviceAuthorizationPluginSchema() {
460
538
  return {
461
539
  deviceCode: AUTH_DEVICE_CODE_SCHEMA
@@ -520,6 +598,9 @@ function readDisableSignUpEnv() {
520
598
  if (signupEnabled != null) return !signupEnabled;
521
599
  return readBooleanEnv("OS_DISABLE_SIGNUP");
522
600
  }
601
+ function readSsoOnlyEnv() {
602
+ return readBooleanEnv("OS_AUTH_SSO_ONLY");
603
+ }
523
604
  var AuthManager = class {
524
605
  constructor(config) {
525
606
  this.auth = null;
@@ -559,6 +640,14 @@ var AuthManager = class {
559
640
  // createAdapterFactory.
560
641
  user: {
561
642
  ...AUTH_USER_CONFIG
643
+ // NOTE: the env-side AI-seat marker `sys_user.ai_access` is deliberately
644
+ // NOT declared as a better-auth additionalField. sys_user is a
645
+ // better-auth-MANAGED table and better-auth SELECTs explicit columns, so
646
+ // declaring it here would make getSession query a column that may not
647
+ // exist on every env yet → broken auth. Instead the column is owned by
648
+ // the objectql `SysUser` object def (provisioned by boot schema-sync)
649
+ // and read by a GUARDED system query in resolveCtx (can only no-op,
650
+ // never break auth). better-auth stays oblivious to the extra column.
562
651
  },
563
652
  account: {
564
653
  ...AUTH_ACCOUNT_CONFIG,
@@ -607,7 +696,7 @@ var AuthManager = class {
607
696
  // lock the registration policy without relying on UI state.
608
697
  emailAndPassword: (() => {
609
698
  const disableSignUpFromEnv = readDisableSignUpEnv();
610
- const effectiveDisableSignUp = disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
699
+ const effectiveDisableSignUp = this.resolveSsoOnly() ? true : disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
611
700
  return {
612
701
  enabled: this.config.emailAndPassword?.enabled ?? true,
613
702
  ...passwordHasher ? { password: passwordHasher } : {},
@@ -621,28 +710,28 @@ var AuthManager = class {
621
710
  sendResetPassword: async ({ user, url, token }) => {
622
711
  const email = this.getEmailService();
623
712
  if (!email) {
624
- console.warn(
625
- `[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`
713
+ throw new Error(
714
+ `Password-reset email could not be sent to ${user.email}: no email service is configured for this deployment.`
626
715
  );
627
- return;
628
716
  }
629
717
  const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
630
- try {
631
- await email.sendTemplate({
632
- template: "auth.password_reset",
633
- to: { address: user.email, ...user.name ? { name: user.name } : {} },
634
- data: {
635
- user: { name: user.name || user.email, email: user.email, id: user.id },
636
- resetUrl: url,
637
- token,
638
- expiresInMinutes: Math.round(ttlSec / 60),
639
- appName: this.getAppName()
640
- },
641
- relatedObject: "sys_user",
642
- relatedId: user.id
643
- });
644
- } catch (err) {
645
- console.error(`[AuthManager] sendResetPassword failed (swallowed): ${err?.message ?? err}`);
718
+ const result = await email.sendTemplate({
719
+ template: "auth.password_reset",
720
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
721
+ data: {
722
+ user: { name: user.name || user.email, email: user.email, id: user.id },
723
+ resetUrl: url,
724
+ token,
725
+ expiresInMinutes: Math.round(ttlSec / 60),
726
+ appName: this.getAppName()
727
+ },
728
+ relatedObject: "sys_user",
729
+ relatedId: user.id
730
+ });
731
+ if (result?.status === "failed") {
732
+ throw new Error(
733
+ `Password-reset email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
734
+ );
646
735
  }
647
736
  }
648
737
  };
@@ -657,28 +746,28 @@ var AuthManager = class {
657
746
  sendVerificationEmail: async ({ user, url, token }) => {
658
747
  const email = this.getEmailService();
659
748
  if (!email) {
660
- console.warn(
661
- `[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`
749
+ throw new Error(
750
+ `Verification email could not be sent to ${user.email}: no email service is configured for this deployment.`
662
751
  );
663
- return;
664
752
  }
665
753
  const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
666
- try {
667
- await email.sendTemplate({
668
- template: "auth.verify_email",
669
- to: { address: user.email, ...user.name ? { name: user.name } : {} },
670
- data: {
671
- user: { name: user.name || user.email, email: user.email, id: user.id },
672
- verificationUrl: url,
673
- token,
674
- expiresInMinutes: Math.round(ttlSec / 60),
675
- appName: this.getAppName()
676
- },
677
- relatedObject: "sys_user",
678
- relatedId: user.id
679
- });
680
- } catch (err) {
681
- console.error(`[AuthManager] sendVerificationEmail failed (swallowed): ${err?.message ?? err}`);
754
+ const result = await email.sendTemplate({
755
+ template: "auth.verify_email",
756
+ to: { address: user.email, ...user.name ? { name: user.name } : {} },
757
+ data: {
758
+ user: { name: user.name || user.email, email: user.email, id: user.id },
759
+ verificationUrl: url,
760
+ token,
761
+ expiresInMinutes: Math.round(ttlSec / 60),
762
+ appName: this.getAppName()
763
+ },
764
+ relatedObject: "sys_user",
765
+ relatedId: user.id
766
+ });
767
+ if (result?.status === "failed") {
768
+ throw new Error(
769
+ `Verification email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
770
+ );
682
771
  }
683
772
  }
684
773
  }
@@ -691,12 +780,18 @@ var AuthManager = class {
691
780
  updateAge: this.config.session?.updateAge || 60 * 60 * 24
692
781
  // 1 day default
693
782
  },
783
+ // ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
784
+ // so better-auth keeps its own defaults otherwise. The settings bind
785
+ // supplies stricter `customRules` for the auth endpoints.
786
+ ...this.config.rateLimit ? { rateLimit: this.config.rateLimit } : {},
694
787
  // better-auth plugins — registered based on AuthPluginConfig flags
695
788
  plugins,
696
789
  // Database hooks (fired by better-auth's adapter writes — these run
697
790
  // for SSO JIT-provisioning too, unlike kernel-level ObjectQL
698
- // middleware which better-auth's adapter bypasses).
699
- ...this.config.databaseHooks ? { databaseHooks: this.config.databaseHooks } : {},
791
+ // middleware which better-auth's adapter bypasses). The framework's
792
+ // identity-source stamp (`account.create.after`) is always composed in,
793
+ // preserving any host-supplied hooks.
794
+ databaseHooks: this.composeDatabaseHooks(this.config.databaseHooks),
700
795
  // Bootstrap bypass for `disableSignUp`. The first-run owner wizard
701
796
  // (`/_account/setup`) calls `POST /auth/sign-up/email` to create
702
797
  // the very first user — if `OS_DISABLE_SIGNUP=true` is set on a
@@ -707,6 +802,126 @@ var AuthManager = class {
707
802
  // sees `userCount > 0` and the toggle is enforced again.
708
803
  hooks: {
709
804
  before: createAuthMiddleware(async (ctx) => {
805
+ if (ctx?.path === "/sign-up/email" || ctx?.path === "/reset-password" || ctx?.path === "/change-password") {
806
+ const candidate = typeof ctx?.body?.password === "string" && ctx.body.password || typeof ctx?.body?.newPassword === "string" && ctx.body.newPassword || "";
807
+ if (candidate) await this.assertPasswordComplexity(candidate);
808
+ if (candidate && (ctx?.path === "/reset-password" || ctx?.path === "/change-password")) {
809
+ const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => void 0);
810
+ if (userId) {
811
+ const pw = ctx?.context?.password;
812
+ const verify = typeof pw?.verify === "function" ? pw.verify.bind(pw) : void 0;
813
+ const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
814
+ if (oldHash !== void 0) ctx.context.__osPwHistory = { userId, oldHash };
815
+ }
816
+ }
817
+ }
818
+ if (ctx?.path === "/sso/register") {
819
+ const actor = await this.resolveActor(ctx);
820
+ if (actor?.userId) {
821
+ const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId);
822
+ if (!ok) {
823
+ const { APIError } = await import("better-auth/api");
824
+ throw new APIError("FORBIDDEN", {
825
+ message: "Only an organization owner/admin or a platform admin can register an SSO provider.",
826
+ code: "SSO_REGISTER_FORBIDDEN"
827
+ });
828
+ }
829
+ }
830
+ return;
831
+ }
832
+ if (ctx?.path === "/oauth2/authorize" && this.config.oidcAuthorizeGate) {
833
+ const clientId = ctx?.query?.client_id;
834
+ if (clientId) {
835
+ let gateUserId;
836
+ try {
837
+ const { getSessionFromCtx } = await import("better-auth/api");
838
+ const s = await getSessionFromCtx(ctx);
839
+ gateUserId = s?.user?.id ?? s?.session?.userId;
840
+ } catch {
841
+ }
842
+ if (!gateUserId) {
843
+ try {
844
+ const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
845
+ let token;
846
+ const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
847
+ if (bm?.[1]) token = bm[1].trim();
848
+ if (!token) {
849
+ const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
850
+ if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
851
+ }
852
+ if (token) {
853
+ const sess = await ctx.context.adapter.findOne({
854
+ model: "session",
855
+ where: [{ field: "token", value: token }]
856
+ });
857
+ const exp = sess?.expiresAt ?? sess?.expires_at;
858
+ if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
859
+ gateUserId = String(sess.userId ?? sess.user_id ?? "") || void 0;
860
+ }
861
+ }
862
+ } catch {
863
+ }
864
+ }
865
+ if (gateUserId) {
866
+ const allowed = await this.config.oidcAuthorizeGate({
867
+ userId: gateUserId,
868
+ clientId: String(clientId)
869
+ });
870
+ if (!allowed) {
871
+ const { APIError } = await import("better-auth/api");
872
+ throw new APIError("FORBIDDEN", {
873
+ message: "You are not authorized to sign in to this environment.",
874
+ code: "ENV_ACCESS_DENIED"
875
+ });
876
+ }
877
+ }
878
+ }
879
+ return;
880
+ }
881
+ if (ctx?.path === "/delete-user" || ctx?.path === "/admin/remove-user" || ctx?.path === "/admin/ban-user") {
882
+ let isLastLocalCredential = false;
883
+ try {
884
+ const adapter = ctx.context.adapter;
885
+ let targetId = ctx?.body?.userId ?? ctx?.body?.user_id;
886
+ if (!targetId && ctx.path === "/delete-user") {
887
+ const { getSessionFromCtx } = await import("better-auth/api");
888
+ const s = await getSessionFromCtx(ctx).catch(() => null);
889
+ targetId = s?.user?.id ?? s?.session?.userId;
890
+ }
891
+ if (targetId) {
892
+ const targetCred = await adapter.findOne({
893
+ model: "account",
894
+ where: [
895
+ { field: "userId", value: targetId },
896
+ { field: "providerId", value: "credential" }
897
+ ]
898
+ });
899
+ if (targetCred) {
900
+ const creds = await adapter.findMany({
901
+ model: "account",
902
+ where: [{ field: "providerId", value: "credential" }]
903
+ });
904
+ const otherHolders = new Set(
905
+ (creds ?? []).map((a) => a?.userId ?? a?.user_id).filter((id) => id && id !== targetId)
906
+ );
907
+ isLastLocalCredential = otherHolders.size === 0;
908
+ }
909
+ }
910
+ } catch {
911
+ }
912
+ if (isLastLocalCredential) {
913
+ const { APIError } = await import("better-auth/api");
914
+ throw new APIError("CONFLICT", {
915
+ 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.",
916
+ code: "LAST_LOCAL_CREDENTIAL"
917
+ });
918
+ }
919
+ }
920
+ if (ctx?.path === "/sign-in/email") {
921
+ const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
922
+ if (email) await this.assertAccountNotLocked(email);
923
+ return;
924
+ }
710
925
  if (ctx?.path !== "/sign-up/email") return;
711
926
  const ep = ctx?.context?.options?.emailAndPassword;
712
927
  if (!ep?.disableSignUp) return;
@@ -721,6 +936,35 @@ var AuthManager = class {
721
936
  }
722
937
  }),
723
938
  after: createAuthMiddleware(async (ctx) => {
939
+ if (ctx?.path === "/sign-in/email") {
940
+ const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
941
+ if (email) {
942
+ let succeeded = true;
943
+ try {
944
+ const { isAPIError } = await import("better-auth/api");
945
+ succeeded = !isAPIError(ctx?.context?.returned);
946
+ } catch {
947
+ succeeded = !(ctx?.context?.returned instanceof Error);
948
+ }
949
+ await this.recordSignInOutcome(email, succeeded);
950
+ }
951
+ return;
952
+ }
953
+ if (ctx?.path === "/change-password" || ctx?.path === "/reset-password") {
954
+ const stash = ctx?.context?.__osPwHistory;
955
+ if (stash?.userId) {
956
+ let succeeded = true;
957
+ try {
958
+ const { isAPIError } = await import("better-auth/api");
959
+ succeeded = !isAPIError(ctx?.context?.returned);
960
+ } catch {
961
+ succeeded = !(ctx?.context?.returned instanceof Error);
962
+ }
963
+ if (succeeded) await this.recordPasswordHistory(stash.userId, stash.oldHash);
964
+ delete ctx.context.__osPwHistory;
965
+ }
966
+ return;
967
+ }
724
968
  if (ctx?.path !== "/sign-up/email") return;
725
969
  const ep = ctx?.context?.options?.emailAndPassword;
726
970
  if (ep && ctx.context.__osDisableSignUpOrig !== void 0) {
@@ -744,6 +988,20 @@ var AuthManager = class {
744
988
  origins.push("http://*.localhost:*");
745
989
  origins.push("https://*.localhost:*");
746
990
  }
991
+ if (this.isSsoWired()) {
992
+ return {
993
+ trustedOrigins: async (request) => {
994
+ const base = [...origins];
995
+ try {
996
+ for (const o of await this.ssoDiscoveryTrustedOrigins(request)) {
997
+ if (!base.includes(o)) base.push(o);
998
+ }
999
+ } catch {
1000
+ }
1001
+ return base;
1002
+ }
1003
+ };
1004
+ }
747
1005
  return origins.length ? { trustedOrigins: origins } : {};
748
1006
  })(),
749
1007
  // Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
@@ -821,15 +1079,24 @@ var AuthManager = class {
821
1079
  const plugins = [];
822
1080
  const oidcEnv = globalThis?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
823
1081
  const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === "true" : void 0;
1082
+ const ssoEnv = globalThis?.process?.env?.OS_SSO_ENABLED;
1083
+ const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === "true" : void 0;
1084
+ const scimEnv = globalThis?.process?.env?.OS_SCIM_ENABLED;
1085
+ const scimFromEnv = scimEnv != null ? String(scimEnv).toLowerCase() === "true" : void 0;
1086
+ const scimEffective = scimFromEnv ?? pluginConfig.scim ?? false;
824
1087
  const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
1088
+ const hibpFromEnv = readBooleanEnv("OS_AUTH_PASSWORD_REJECT_BREACHED");
825
1089
  const enabled = {
826
1090
  organization: pluginConfig.organization ?? true,
827
1091
  twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false,
1092
+ passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false,
828
1093
  passkeys: pluginConfig.passkeys ?? false,
829
1094
  magicLink: pluginConfig.magicLink ?? false,
830
1095
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
831
1096
  deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
832
- admin: pluginConfig.admin ?? false
1097
+ admin: pluginConfig.admin ?? scimEffective,
1098
+ sso: ssoFromEnv ?? pluginConfig.sso ?? false,
1099
+ scim: scimEffective
833
1100
  };
834
1101
  const { bearer } = await import("better-auth/plugins/bearer");
835
1102
  plugins.push(bearer());
@@ -872,6 +1139,28 @@ var AuthManager = class {
872
1139
  // the built-in /accept-invitation route usable for pilots; operators
873
1140
  // who wire a real mailer can re-enable downstream.
874
1141
  requireEmailVerificationOnInvitation: false,
1142
+ // Cap how many orgs a user can CREATE (OS_ORG_LIMIT). Counts only orgs
1143
+ // the user OWNS (role=owner) — never orgs they were merely invited into —
1144
+ // so a generous cap stops scripted org/free-env spam (each new org can
1145
+ // auto-provision a free environment on the cloud control plane) WITHOUT
1146
+ // ever blocking a collaborator who belongs to many orgs. Unset → no
1147
+ // limit (self-host default). Fail-open: if the count can't be taken we
1148
+ // allow creation rather than block a legitimate user on an infra hiccup.
1149
+ organizationLimit: async (user) => {
1150
+ const limit = resolveOrgLimit();
1151
+ if (limit == null) return false;
1152
+ const engine = this.config.dataEngine;
1153
+ const uid = typeof user?.id === "string" ? user.id : "";
1154
+ if (!engine || !uid) return false;
1155
+ try {
1156
+ const owned = await withSystemReadContext(engine).count("sys_member", {
1157
+ where: { user_id: uid, role: "owner" }
1158
+ });
1159
+ return (owned ?? 0) >= limit;
1160
+ } catch {
1161
+ return false;
1162
+ }
1163
+ },
875
1164
  ...customOrgRoles ? { roles: customOrgRoles } : {},
876
1165
  // ── Slug-change guard ─────────────────────────────────────
877
1166
  // An org's slug is baked into every env hostname at creation
@@ -894,17 +1183,32 @@ var AuthManager = class {
894
1183
  // 2. else `OS_MULTI_TENANT` (multi-tenant deployments are always
895
1184
  // multi-org), default `'false'` → single-org / per-env runtime.
896
1185
  beforeCreateOrganization: async () => {
897
- const env = globalThis?.process?.env ?? {};
898
- const explicit = env.OS_MULTI_ORG_ENABLED;
899
- const legacy = explicit === void 0 ? readEnvWithDeprecation("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") : explicit;
900
- const flag = String(legacy ?? "false").toLowerCase();
901
- if (flag === "false") {
1186
+ if (!resolveMultiOrgEnabled()) {
902
1187
  const { APIError } = await import("better-auth/api");
903
1188
  throw new APIError("FORBIDDEN", {
904
1189
  message: "Creating additional organizations is disabled on this deployment."
905
1190
  });
906
1191
  }
907
1192
  },
1193
+ // Run host-provided org-creation side effects (e.g. the cloud control
1194
+ // plane provisions the org's born-with production environment). The
1195
+ // org-plugin's models don't fire core databaseHooks, so this is the
1196
+ // only server-side seam for "every org is born with its prod env".
1197
+ // Failure-isolated: org creation must not roll back on a side-effect miss.
1198
+ afterCreateOrganization: async ({ organization: organization2, member, user }) => {
1199
+ const cb = this.config.onOrganizationCreated;
1200
+ if (typeof cb !== "function") return;
1201
+ try {
1202
+ await cb({
1203
+ organizationId: organization2?.id,
1204
+ userId: user?.id ?? member?.userId,
1205
+ name: organization2?.name,
1206
+ slug: organization2?.slug
1207
+ });
1208
+ } catch (err) {
1209
+ console.warn("[auth] onOrganizationCreated callback failed:", err?.message ?? String(err));
1210
+ }
1211
+ },
908
1212
  beforeUpdateOrganization: async ({ organization: organization2, member }) => {
909
1213
  const newSlug = organization2?.slug;
910
1214
  const orgId = member?.organizationId;
@@ -982,6 +1286,12 @@ var AuthManager = class {
982
1286
  schema: buildTwoFactorPluginSchema()
983
1287
  }));
984
1288
  }
1289
+ if (enabled.passwordRejectBreached) {
1290
+ const { haveIBeenPwned } = await import("better-auth/plugins/haveibeenpwned");
1291
+ plugins.push(haveIBeenPwned({
1292
+ customPasswordCompromisedMessage: "This password has appeared in a known data breach. Please choose a different one."
1293
+ }));
1294
+ }
985
1295
  if (enabled.admin) {
986
1296
  const { admin } = await import("better-auth/plugins/admin");
987
1297
  plugins.push(admin({
@@ -1049,6 +1359,16 @@ var AuthManager = class {
1049
1359
  schema: buildOauthProviderPluginSchema()
1050
1360
  }));
1051
1361
  }
1362
+ if (enabled.sso) {
1363
+ const { sso } = await import("@better-auth/sso");
1364
+ plugins.push(sso({
1365
+ organizationProvisioning: { defaultRole: "member" }
1366
+ }));
1367
+ }
1368
+ if (enabled.scim) {
1369
+ const { scim } = await import("@better-auth/scim");
1370
+ plugins.push(scim({ storeSCIMToken: "hashed" }));
1371
+ }
1052
1372
  if (enabled.deviceAuthorization) {
1053
1373
  const { deviceAuthorization } = await import("better-auth/plugins/device-authorization");
1054
1374
  const baseUrl = (this.config.baseUrl ?? "").replace(/\/$/, "");
@@ -1085,30 +1405,36 @@ var AuthManager = class {
1085
1405
  return false;
1086
1406
  }
1087
1407
  };
1088
- const isActiveOrgAdmin = async () => {
1408
+ const activeOrgRoles = async () => {
1089
1409
  try {
1090
1410
  const orgId = session?.activeOrganizationId;
1091
- if (!orgId) return false;
1411
+ if (!orgId) return [];
1092
1412
  const members = await dataEngine.find("sys_member", {
1093
1413
  where: { user_id: user.id, organization_id: orgId },
1094
1414
  limit: 5
1095
1415
  });
1096
- return (Array.isArray(members) ? members : []).some((m) => {
1416
+ const out = [];
1417
+ for (const m of Array.isArray(members) ? members : []) {
1097
1418
  const raw = typeof m?.role === "string" ? m.role : "";
1098
- const roles2 = raw.split(",").map((s) => s.trim().toLowerCase());
1099
- return roles2.includes("owner") || roles2.includes("admin");
1100
- });
1419
+ for (const r of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
1420
+ const mapped = mapMembershipRole(r);
1421
+ if (!out.includes(mapped)) out.push(mapped);
1422
+ }
1423
+ }
1424
+ return out;
1101
1425
  } catch {
1102
- return false;
1426
+ return [];
1103
1427
  }
1104
1428
  };
1105
1429
  const platformAdmin = await isPlatformAdmin();
1106
- const promote = platformAdmin || await isActiveOrgAdmin();
1430
+ const orgRoles = await activeOrgRoles();
1107
1431
  const storedRole = typeof user.role === "string" ? user.role : "";
1108
- const roles = storedRole.split(",").map((s) => s.trim()).filter(Boolean);
1109
- if (promote && !roles.includes("admin")) roles.push("admin");
1110
- if (!promote) return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
1111
- return { user: { ...user, role: "admin", roles, isPlatformAdmin: platformAdmin }, session };
1432
+ const roles = Array.from(/* @__PURE__ */ new Set([
1433
+ ...storedRole.split(",").map((s) => s.trim()).filter(Boolean),
1434
+ ...orgRoles,
1435
+ ...platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []
1436
+ ]));
1437
+ return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
1112
1438
  }));
1113
1439
  }
1114
1440
  return plugins;
@@ -1307,6 +1633,18 @@ var AuthManager = class {
1307
1633
  // `sys_device_code`. Enable via `plugins.deviceAuthorization: true` in
1308
1634
  // AuthPluginConfig.
1309
1635
  // ---------------------------------------------------------------------------
1636
+ /**
1637
+ * SSO-only ("enforced") login mode: the login UI hides the local password
1638
+ * form + self-registration so the team signs in via the IdP only.
1639
+ * `OS_AUTH_SSO_ONLY` (when set) wins over the `ssoOnlyMode` config knob —
1640
+ * parity with the `disableSignUp` env override — so a deployment can force
1641
+ * it regardless of the per-env/config value. Break-glass is preserved: this
1642
+ * NEVER disables `emailAndPassword.enabled`; it only forces `disableSignUp`
1643
+ * and signals the UI to hide the password form. Generic over the IdP.
1644
+ */
1645
+ resolveSsoOnly() {
1646
+ return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false);
1647
+ }
1310
1648
  getPublicConfig() {
1311
1649
  const socialProviders = [];
1312
1650
  if (this.config.socialProviders) {
@@ -1344,16 +1682,15 @@ var AuthManager = class {
1344
1682
  }
1345
1683
  const emailPasswordConfig = this.config.emailAndPassword ?? {};
1346
1684
  const disableSignUpFromEnv = readDisableSignUpEnv();
1685
+ const ssoOnly = this.resolveSsoOnly();
1347
1686
  const emailPassword = {
1348
1687
  enabled: emailPasswordConfig.enabled !== false,
1349
1688
  // Default to true
1350
- disableSignUp: disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
1689
+ disableSignUp: ssoOnly ? true : disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
1351
1690
  requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false
1352
1691
  };
1353
1692
  const pluginConfig = this.config.plugins ?? {};
1354
- const multiOrgEnv = globalThis?.process?.env ?? {};
1355
- const multiOrgRaw = multiOrgEnv.OS_MULTI_ORG_ENABLED !== void 0 ? multiOrgEnv.OS_MULTI_ORG_ENABLED : readEnvWithDeprecation("OS_MULTI_ORG_ENABLED", "OS_MULTI_TENANT") ?? "false";
1356
- const multiOrgEnabled = String(multiOrgRaw).toLowerCase() !== "false";
1693
+ const multiOrgEnabled = resolveMultiOrgEnabled();
1357
1694
  const DEFAULT_TERMS_URL = "https://objectstack.ai/terms";
1358
1695
  const DEFAULT_PRIVACY_URL = "https://objectstack.ai/privacy";
1359
1696
  const rawTermsUrl = globalThis?.process?.env?.OS_TERMS_URL;
@@ -1376,6 +1713,15 @@ var AuthManager = class {
1376
1713
  organization: pluginConfig.organization ?? true,
1377
1714
  multiOrgEnabled,
1378
1715
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
1716
+ // Coarse "is the @better-auth/sso plugin wired" flag. The `/auth/config`
1717
+ // route refines this to "usable" (≥1 provider configured) via
1718
+ // `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button
1719
+ // both when SSO is off AND when it's on but no IdP exists yet.
1720
+ sso: this.isSsoWired(),
1721
+ // SSO-only ("enforced"): tell the login UI to hide the local password
1722
+ // form + self-registration. A break-glass "use a password" link remains
1723
+ // for the env owner / local admin. Driven by `ssoOnlyMode` / `OS_AUTH_SSO_ONLY`.
1724
+ ssoEnforced: ssoOnly,
1379
1725
  deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
1380
1726
  admin: pluginConfig.admin ?? false,
1381
1727
  ...termsUrl ? { termsUrl } : {},
@@ -1387,6 +1733,493 @@ var AuthManager = class {
1387
1733
  features
1388
1734
  };
1389
1735
  }
1736
+ /**
1737
+ * Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag.
1738
+ * Resolved with the EXACT logic that decides whether the plugin is mounted
1739
+ * in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
1740
+ * advertised capability can never disagree with the actual `/sign-in/sso`
1741
+ * route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
1742
+ */
1743
+ isSsoWired() {
1744
+ const ssoEnv = globalThis?.process?.env?.OS_SSO_ENABLED;
1745
+ const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === "true" : void 0;
1746
+ return ssoFromEnv ?? this.config.plugins?.sso ?? false;
1747
+ }
1748
+ /**
1749
+ * Whether enterprise SSO is actually *usable*, not merely wired: the plugin
1750
+ * is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
1751
+ * matching still happens at `/sign-in/sso`; this answers the coarser "is
1752
+ * there any point showing the SSO button at all", so a freshly-enabled but
1753
+ * unconfigured SSO setup doesn't advertise a button that errors for everyone.
1754
+ *
1755
+ * Fails OPEN to the wired flag when providers can't be counted (no data
1756
+ * engine, query error) — a config-introspection hiccup must never make the
1757
+ * login page hide a button that genuinely works.
1758
+ */
1759
+ async isSsoUsable() {
1760
+ if (!this.isSsoWired()) return false;
1761
+ const engine = this.getDataEngine();
1762
+ if (!engine) return true;
1763
+ try {
1764
+ const count = await withSystemReadContext(engine).count("sys_sso_provider");
1765
+ return typeof count === "number" ? count > 0 : true;
1766
+ } catch {
1767
+ return true;
1768
+ }
1769
+ }
1770
+ /**
1771
+ * Extra `trustedOrigins` entries derived from an external-SSO registration
1772
+ * request. For a `POST /sso/register` | `/sso/update-provider`, parse the
1773
+ * (cloned) body and return the PUBLIC-ROUTABLE origins of the declared
1774
+ * `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery
1775
+ * validation accepts a customer IdP registered at runtime (ADR-0024) without
1776
+ * the operator pre-listing it in boot config. Only public-routable hosts are
1777
+ * returned — private / internal / loopback hosts are never auto-trusted
1778
+ * (better-auth's `isPublicRoutableHost`, the same predicate its own
1779
+ * sub-endpoint check uses). Best-effort: any parse error yields `[]`.
1780
+ */
1781
+ async ssoDiscoveryTrustedOrigins(request) {
1782
+ try {
1783
+ const req = request;
1784
+ if (!req || typeof req.clone !== "function" || !req.url) return [];
1785
+ if ((req.method ?? "GET").toUpperCase() !== "POST") return [];
1786
+ const path = new URL(req.url).pathname;
1787
+ if (!/\/sso\/(register|update-provider)$/.test(path)) return [];
1788
+ const body = await req.clone().json().catch(() => null);
1789
+ if (!body || typeof body !== "object") return [];
1790
+ const oidc = body.oidcConfig ?? {};
1791
+ const candidates = [
1792
+ body.issuer,
1793
+ oidc.discoveryEndpoint,
1794
+ oidc.authorizationEndpoint,
1795
+ oidc.tokenEndpoint,
1796
+ oidc.jwksEndpoint,
1797
+ oidc.userInfoEndpoint
1798
+ ].filter((v) => typeof v === "string" && v.length > 0);
1799
+ if (!candidates.length) return [];
1800
+ const { isPublicRoutableHost } = await import("@better-auth/core/utils/host");
1801
+ const out = [];
1802
+ for (const c of candidates) {
1803
+ try {
1804
+ const u = new URL(c);
1805
+ if (isPublicRoutableHost(u.hostname) && !out.includes(u.origin)) out.push(u.origin);
1806
+ } catch {
1807
+ }
1808
+ }
1809
+ return out;
1810
+ } catch {
1811
+ return [];
1812
+ }
1813
+ }
1814
+ /**
1815
+ * Resolve the acting user (+ their active org) for a before-hook gate,
1816
+ * hook-order-independent. Tries the standard cookie session first, then falls
1817
+ * back to explicit token resolution (bearer or the session cookie's token
1818
+ * part) — the bearer plugin may convert `Authorization: Bearer` to a session
1819
+ * AFTER this global before-hook runs. Returns `null` when no valid session
1820
+ * can be resolved (→ caller lets `sessionMiddleware` issue the 401).
1821
+ */
1822
+ async resolveActor(ctx) {
1823
+ try {
1824
+ const { getSessionFromCtx } = await import("better-auth/api");
1825
+ const s = await getSessionFromCtx(ctx);
1826
+ const userId = s?.user?.id ?? s?.session?.userId;
1827
+ if (userId) {
1828
+ return {
1829
+ userId: String(userId),
1830
+ activeOrgId: s?.session?.activeOrganizationId ?? s?.activeOrganizationId ?? void 0
1831
+ };
1832
+ }
1833
+ } catch {
1834
+ }
1835
+ try {
1836
+ const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
1837
+ let token;
1838
+ const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
1839
+ if (bm?.[1]) token = bm[1].trim();
1840
+ if (!token) {
1841
+ const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
1842
+ if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
1843
+ }
1844
+ if (token) {
1845
+ const sess = await ctx.context.adapter.findOne({
1846
+ model: "session",
1847
+ where: [{ field: "token", value: token }]
1848
+ });
1849
+ const exp = sess?.expiresAt ?? sess?.expires_at;
1850
+ if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
1851
+ const userId = String(sess.userId ?? sess.user_id ?? "");
1852
+ if (userId) {
1853
+ return {
1854
+ userId,
1855
+ activeOrgId: sess.activeOrganizationId ?? sess.active_organization_id ?? void 0
1856
+ };
1857
+ }
1858
+ }
1859
+ }
1860
+ } catch {
1861
+ }
1862
+ return null;
1863
+ }
1864
+ /**
1865
+ * True when `userId` is a platform admin (a `sys_user_permission_set` row
1866
+ * pointing at `admin_full_access` with `organization_id = null`) OR an
1867
+ * owner/admin member of `activeOrgId` (any org membership with role
1868
+ * owner/admin when no active org is set). Mirrors the role-derivation in
1869
+ * `customSession`; reads through `withSystemReadContext` so the lookups are
1870
+ * not themselves RLS-scoped to the acting (possibly non-privileged) user.
1871
+ * Fails CLOSED (returns false) on any lookup error — this backs a security
1872
+ * gate, so an unverifiable actor must never pass.
1873
+ */
1874
+ async isOrgOrPlatformAdmin(userId, activeOrgId) {
1875
+ const engine = this.getDataEngine();
1876
+ if (!engine) return false;
1877
+ const sys = withSystemReadContext(engine);
1878
+ try {
1879
+ const links = await sys.find("sys_user_permission_set", {
1880
+ where: { user_id: userId },
1881
+ limit: 50
1882
+ });
1883
+ const platformLinks = (Array.isArray(links) ? links : []).filter(
1884
+ (l) => !l.organization_id
1885
+ );
1886
+ if (platformLinks.length) {
1887
+ const sets = await sys.find("sys_permission_set", { limit: 50 });
1888
+ const adminSet = (Array.isArray(sets) ? sets : []).find(
1889
+ (r) => r.name === "admin_full_access"
1890
+ );
1891
+ if (adminSet && platformLinks.some((l) => l.permission_set_id === adminSet.id)) {
1892
+ return true;
1893
+ }
1894
+ }
1895
+ const where = { user_id: userId };
1896
+ if (activeOrgId) where.organization_id = activeOrgId;
1897
+ const members = await sys.find("sys_member", { where, limit: 10 });
1898
+ for (const m of Array.isArray(members) ? members : []) {
1899
+ const raw = typeof m?.role === "string" ? m.role : "";
1900
+ if (raw.split(",").map((s) => s.trim()).some((r) => r === "owner" || r === "admin")) {
1901
+ return true;
1902
+ }
1903
+ }
1904
+ return false;
1905
+ } catch {
1906
+ return false;
1907
+ }
1908
+ }
1909
+ /**
1910
+ * Compose the framework's identity-source stamp (`account.create.after`)
1911
+ * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes
1912
+ * `user.create.after` (personal-org provisioning) + `session.create.before`
1913
+ * (active-org) — different model/op, so no collision — but if a host ever
1914
+ * adds its own `account.create.after` we chain it after the stamp rather
1915
+ * than silently dropping one.
1916
+ */
1917
+ composeDatabaseHooks(host) {
1918
+ const stamp = (account, ctx) => this.stampIdentitySource(account, ctx);
1919
+ const hostAccountAfter = host?.account?.create?.after;
1920
+ const after = hostAccountAfter ? async (account, ctx) => {
1921
+ await stamp(account, ctx);
1922
+ return hostAccountAfter(account, ctx);
1923
+ } : stamp;
1924
+ return {
1925
+ ...host ?? {},
1926
+ account: {
1927
+ ...host?.account ?? {},
1928
+ create: {
1929
+ ...host?.account?.create ?? {},
1930
+ after
1931
+ }
1932
+ }
1933
+ };
1934
+ }
1935
+ /**
1936
+ * Maintain `sys_user.source` (ADR-0024 D4 provenance) as accounts are linked.
1937
+ * Drives the managed-vs-native user-mgmt gating: a managed (`idp-provisioned`)
1938
+ * user holds no local credential, so the password / identity-edit actions
1939
+ * hide for them — preventing a managed user from self-minting a local
1940
+ * password that would bypass enforced SSO.
1941
+ *
1942
+ * Two cases, both break-glass safe and idempotent (only writes on a real
1943
+ * change, so trackHistory stays quiet):
1944
+ *
1945
+ * • A **federated** account (any non-`credential` provider — the cloud-as-IdP
1946
+ * `objectstack-cloud` provider OR a customer's own OIDC/SAML IdP) is
1947
+ * linked AND the user holds NO local credential → mark `idp-provisioned`.
1948
+ * A user who already has a `credential` account (an env-native user who
1949
+ * linked SSO) is left `env-native` — they keep a usable password.
1950
+ *
1951
+ * • A **credential** account is created (local signup, or the break-glass
1952
+ * owner's password set via set-initial-password — which can land AFTER the
1953
+ * first SSO link) → ensure `env-native`. This flips a previously-stamped
1954
+ * owner back, so the break-glass admin never loses self-service password
1955
+ * management.
1956
+ *
1957
+ * Best-effort: any failure leaves the prior value (the gate fails open — a
1958
+ * managed user might transiently show a password action that simply errors —
1959
+ * never a hard login failure).
1960
+ */
1961
+ async stampIdentitySource(account, _ctx) {
1962
+ try {
1963
+ const providerId = account?.providerId ?? account?.provider_id;
1964
+ const userId = account?.userId ?? account?.user_id;
1965
+ if (!userId || !providerId) return;
1966
+ const engine = this.getDataEngine();
1967
+ if (!engine) return;
1968
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
1969
+ if (providerId === "credential") {
1970
+ const u = await engine.findOne("sys_user", {
1971
+ filter: { id: userId },
1972
+ fields: ["id", "source"],
1973
+ context: SYSTEM_CTX
1974
+ });
1975
+ if (u && u.source === "idp_provisioned") {
1976
+ await engine.update("sys_user", { id: userId, source: "env_native" }, { context: SYSTEM_CTX });
1977
+ }
1978
+ return;
1979
+ }
1980
+ const credentialCount = await engine.count("sys_account", {
1981
+ filter: { user_id: userId, provider_id: "credential" },
1982
+ context: SYSTEM_CTX
1983
+ });
1984
+ if (typeof credentialCount === "number" && credentialCount > 0) return;
1985
+ await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
1986
+ } catch {
1987
+ }
1988
+ }
1989
+ /**
1990
+ * ADR-0069 D1 — reject a password that doesn't meet the configured character-
1991
+ * class complexity. No-op when `passwordRequireComplexity` is off. Counts the
1992
+ * four classes (upper / lower / digit / symbol) present and throws
1993
+ * `PASSWORD_POLICY_VIOLATION` when fewer than `passwordMinClasses` are used.
1994
+ */
1995
+ async assertPasswordComplexity(password) {
1996
+ if (!this.config.passwordRequireComplexity) return;
1997
+ const min = Math.min(4, Math.max(1, Math.floor(Number(this.config.passwordMinClasses) || 3)));
1998
+ 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);
1999
+ if (classes < min) {
2000
+ const { APIError } = await import("better-auth/api");
2001
+ throw new APIError("BAD_REQUEST", {
2002
+ message: `Password must include at least ${min} of: uppercase, lowercase, digit, symbol.`,
2003
+ code: "PASSWORD_POLICY_VIOLATION"
2004
+ });
2005
+ }
2006
+ }
2007
+ /**
2008
+ * ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
2009
+ * a string[] of hashes, tolerating null / malformed values.
2010
+ */
2011
+ parseHashes(raw) {
2012
+ if (typeof raw !== "string" || !raw.trim()) return [];
2013
+ try {
2014
+ const arr = JSON.parse(raw);
2015
+ return Array.isArray(arr) ? arr.filter((h) => typeof h === "string" && !!h) : [];
2016
+ } catch {
2017
+ return [];
2018
+ }
2019
+ }
2020
+ /**
2021
+ * ADR-0069 D1 — resolve the user whose password is being changed. For
2022
+ * `/change-password` the caller is authenticated (session); for
2023
+ * `/reset-password` the user is carried by the reset token's verification
2024
+ * value (the same lookup better-auth's own handler uses).
2025
+ */
2026
+ async resolvePasswordChangeUserId(ctx) {
2027
+ if (ctx?.path === "/change-password") {
2028
+ const { getSessionFromCtx } = await import("better-auth/api");
2029
+ const sess = await getSessionFromCtx(ctx).catch(() => null);
2030
+ return sess?.user?.id ?? sess?.session?.userId ?? void 0;
2031
+ }
2032
+ if (ctx?.path === "/reset-password") {
2033
+ const token = typeof ctx?.body?.token === "string" ? ctx.body.token : "";
2034
+ if (!token) return void 0;
2035
+ try {
2036
+ const v = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
2037
+ const raw = v?.value;
2038
+ if (!raw) return void 0;
2039
+ if (typeof raw === "string") {
2040
+ const t = raw.trim();
2041
+ if (t.startsWith("{") || t.startsWith('"')) {
2042
+ try {
2043
+ const o = JSON.parse(t);
2044
+ return (typeof o === "string" ? o : o?.userId) ?? void 0;
2045
+ } catch {
2046
+ return t;
2047
+ }
2048
+ }
2049
+ return t;
2050
+ }
2051
+ return raw?.userId ?? void 0;
2052
+ } catch {
2053
+ return void 0;
2054
+ }
2055
+ }
2056
+ return void 0;
2057
+ }
2058
+ /**
2059
+ * ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's
2060
+ * current password or any hash in the bounded history. Reuses better-auth's
2061
+ * native `password.verify` (passed in) rather than re-hashing. Returns the
2062
+ * current hash (for the after-hook to append) when the candidate is fresh, or
2063
+ * undefined when the feature is off / nothing to compare.
2064
+ */
2065
+ async assertPasswordNotReused(userId, candidate, verify) {
2066
+ const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2067
+ if (count <= 0 || typeof verify !== "function") return void 0;
2068
+ const engine = this.getDataEngine();
2069
+ if (!engine) return void 0;
2070
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2071
+ let account;
2072
+ try {
2073
+ account = await engine.findOne("sys_account", {
2074
+ where: { user_id: userId, provider_id: "credential" },
2075
+ fields: ["id", "password", "previous_password_hashes"],
2076
+ context: SYSTEM_CTX
2077
+ });
2078
+ } catch {
2079
+ return void 0;
2080
+ }
2081
+ if (!account?.id) return void 0;
2082
+ const currentHash = typeof account.password === "string" ? account.password : "";
2083
+ const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean);
2084
+ for (const h of compareList) {
2085
+ let match = false;
2086
+ try {
2087
+ match = await verify({ password: candidate, hash: h });
2088
+ } catch {
2089
+ match = false;
2090
+ }
2091
+ if (match) {
2092
+ const { APIError } = await import("better-auth/api");
2093
+ throw new APIError("BAD_REQUEST", {
2094
+ message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`,
2095
+ code: "PASSWORD_REUSE"
2096
+ });
2097
+ }
2098
+ }
2099
+ return currentHash;
2100
+ }
2101
+ /**
2102
+ * ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a
2103
+ * successful change/reset. Best-effort; never throws.
2104
+ */
2105
+ async recordPasswordHistory(userId, oldHash) {
2106
+ const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
2107
+ if (count <= 0 || !oldHash) return;
2108
+ const engine = this.getDataEngine();
2109
+ if (!engine) return;
2110
+ try {
2111
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2112
+ const account = await engine.findOne("sys_account", {
2113
+ where: { user_id: userId, provider_id: "credential" },
2114
+ fields: ["id", "previous_password_hashes"],
2115
+ context: SYSTEM_CTX
2116
+ });
2117
+ if (!account?.id) return;
2118
+ const prev = this.parseHashes(account.previous_password_hashes);
2119
+ const next = [oldHash, ...prev.filter((h) => h !== oldHash)].slice(0, count);
2120
+ await engine.update(
2121
+ "sys_account",
2122
+ { id: account.id, previous_password_hashes: JSON.stringify(next) },
2123
+ { context: SYSTEM_CTX }
2124
+ );
2125
+ } catch {
2126
+ }
2127
+ }
2128
+ /**
2129
+ * ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
2130
+ * out (brute-force protection). No-op when lockout is disabled
2131
+ * (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a
2132
+ * lookup error: an infra hiccup must never block every login.
2133
+ */
2134
+ async assertAccountNotLocked(email) {
2135
+ const threshold = Number(this.config.lockoutThreshold) || 0;
2136
+ if (threshold <= 0) return;
2137
+ const engine = this.getDataEngine();
2138
+ if (!engine) return;
2139
+ let locked = false;
2140
+ try {
2141
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2142
+ const u = await engine.findOne("sys_user", {
2143
+ where: { email },
2144
+ fields: ["id", "locked_until"],
2145
+ context: SYSTEM_CTX
2146
+ });
2147
+ const lu = u?.locked_until;
2148
+ locked = !!(lu && new Date(lu).getTime() > Date.now());
2149
+ } catch {
2150
+ return;
2151
+ }
2152
+ if (locked) {
2153
+ const { APIError } = await import("better-auth/api");
2154
+ throw new APIError("FORBIDDEN", {
2155
+ message: "This account is temporarily locked after too many failed sign-in attempts. Try again later or ask an administrator to unlock it.",
2156
+ code: "ACCOUNT_LOCKED"
2157
+ });
2158
+ }
2159
+ }
2160
+ /**
2161
+ * ADR-0069 D2 — record a sign-in outcome for lockout accounting. On failure
2162
+ * increments `failed_login_count` and, once it reaches `lockoutThreshold`,
2163
+ * stamps `locked_until = now + lockoutDurationMinutes`. On success resets
2164
+ * both (only writing when there is something to clear, to avoid a no-op
2165
+ * history row on every login). No-op when lockout is disabled. Never throws —
2166
+ * a counter write must not turn a valid login into an error.
2167
+ */
2168
+ async recordSignInOutcome(email, success) {
2169
+ const threshold = Number(this.config.lockoutThreshold) || 0;
2170
+ if (threshold <= 0) return;
2171
+ const engine = this.getDataEngine();
2172
+ if (!engine) return;
2173
+ try {
2174
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2175
+ const u = await engine.findOne("sys_user", {
2176
+ where: { email },
2177
+ fields: ["id", "failed_login_count", "locked_until"],
2178
+ context: SYSTEM_CTX
2179
+ });
2180
+ if (!u?.id) return;
2181
+ if (success) {
2182
+ if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) {
2183
+ await engine.update(
2184
+ "sys_user",
2185
+ { id: u.id, failed_login_count: 0, locked_until: null },
2186
+ { context: SYSTEM_CTX }
2187
+ );
2188
+ }
2189
+ return;
2190
+ }
2191
+ const next = (Number(u.failed_login_count) || 0) + 1;
2192
+ const patch = { id: u.id, failed_login_count: next };
2193
+ if (next >= threshold) {
2194
+ const mins = Number(this.config.lockoutDurationMinutes) || 15;
2195
+ patch.locked_until = new Date(Date.now() + mins * 6e4);
2196
+ }
2197
+ await engine.update("sys_user", patch, { context: SYSTEM_CTX });
2198
+ } catch {
2199
+ }
2200
+ }
2201
+ /**
2202
+ * ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
2203
+ * Resets `failed_login_count` and `locked_until`. Returns false when no data
2204
+ * engine is wired or the user does not exist.
2205
+ */
2206
+ async unlockUser(userId) {
2207
+ const engine = this.getDataEngine();
2208
+ if (!engine || !userId) return false;
2209
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2210
+ const u = await engine.findOne("sys_user", {
2211
+ where: { id: userId },
2212
+ fields: ["id"],
2213
+ context: SYSTEM_CTX
2214
+ });
2215
+ if (!u?.id) return false;
2216
+ await engine.update(
2217
+ "sys_user",
2218
+ { id: userId, failed_login_count: 0, locked_until: null },
2219
+ { context: SYSTEM_CTX }
2220
+ );
2221
+ return true;
2222
+ }
1390
2223
  /**
1391
2224
  * Returns the data engine wired into this auth manager. Used by route
1392
2225
  * handlers (e.g. bootstrap-status) that need to query identity tables
@@ -1442,6 +2275,8 @@ import {
1442
2275
  SysOauthRefreshToken,
1443
2276
  SysOrganization,
1444
2277
  SysSession,
2278
+ SysSsoProvider,
2279
+ SysScimProvider,
1445
2280
  SysTeam,
1446
2281
  SysTeamMember,
1447
2282
  SysTwoFactor,
@@ -1469,7 +2304,9 @@ var authIdentityObjects = [
1469
2304
  SysOauthRefreshToken,
1470
2305
  SysOauthConsent,
1471
2306
  SysJwks,
1472
- SysDeviceCode
2307
+ SysDeviceCode,
2308
+ SysSsoProvider,
2309
+ SysScimProvider
1473
2310
  ];
1474
2311
  var authPluginManifestHeader = {
1475
2312
  id: AUTH_PLUGIN_ID,
@@ -1575,14 +2412,24 @@ var AuthPlugin = class {
1575
2412
  ctx.hook("kernel:ready", async () => {
1576
2413
  if (this.authManager) {
1577
2414
  await this.bindAuthSettings(ctx);
2415
+ let emailSvc;
1578
2416
  try {
1579
- const emailSvc = ctx.getService("email");
1580
- if (emailSvc) {
1581
- this.authManager.setEmailService(emailSvc);
1582
- ctx.logger.info("Auth: email service wired (transactional mail enabled)");
1583
- }
2417
+ emailSvc = ctx.getService("email");
1584
2418
  } catch {
1585
- ctx.logger.info("Auth: no email service registered \u2014 auth callbacks will log instead of sending");
2419
+ emailSvc = void 0;
2420
+ }
2421
+ if (emailSvc) {
2422
+ this.authManager.setEmailService(emailSvc);
2423
+ ctx.logger.info("Auth: email service wired (transactional mail enabled)");
2424
+ } else {
2425
+ const requiresEmail = !!this.authManager.getPublicConfig?.()?.emailPassword?.requireEmailVerification;
2426
+ if (requiresEmail) {
2427
+ ctx.logger.error(
2428
+ "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)."
2429
+ );
2430
+ } else {
2431
+ ctx.logger.info("Auth: no email service registered \u2014 transactional mail disabled");
2432
+ }
1586
2433
  }
1587
2434
  try {
1588
2435
  const settings = ctx.getService("settings");
@@ -1645,6 +2492,38 @@ var AuthPlugin = class {
1645
2492
  ctx.hook("kernel:ready", async () => {
1646
2493
  await this.maybeSeedDevAdmin(ctx);
1647
2494
  });
2495
+ ctx.hook("kernel:ready", async () => {
2496
+ try {
2497
+ const engine = ctx.getService("objectql");
2498
+ if (!engine || typeof engine.registerHook !== "function") return;
2499
+ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
2500
+ engine.registerHook("afterInsert", async (hookCtx) => {
2501
+ try {
2502
+ if (hookCtx?.object !== "sys_account") return;
2503
+ const acct = hookCtx.result ?? {};
2504
+ const providerId = acct.provider_id ?? acct.providerId;
2505
+ const userId = acct.user_id ?? acct.userId;
2506
+ if (!userId || !providerId || providerId === "credential") return;
2507
+ const credCount = await engine.count("sys_account", {
2508
+ where: { user_id: userId, provider_id: "credential" },
2509
+ context: SYSTEM_CTX
2510
+ });
2511
+ if (typeof credCount === "number" && credCount > 0) return;
2512
+ const u = await engine.findOne("sys_user", {
2513
+ where: { id: userId },
2514
+ fields: ["id", "source"],
2515
+ context: SYSTEM_CTX
2516
+ });
2517
+ if (u && u.source !== "idp_provisioned") {
2518
+ await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
2519
+ }
2520
+ } catch {
2521
+ }
2522
+ }, { packageId: "com.objectstack.plugin-auth" });
2523
+ ctx.logger.info("Identity-source afterInsert stamp registered on sys_account (SCIM-safe)");
2524
+ } catch {
2525
+ }
2526
+ });
1648
2527
  try {
1649
2528
  const ql = ctx.getService("objectql");
1650
2529
  if (ql && typeof ql.registerMiddleware === "function") {
@@ -1728,6 +2607,23 @@ var AuthPlugin = class {
1728
2607
  if (Object.keys(emailAndPassword).length > 0) {
1729
2608
  patch.emailAndPassword = emailAndPassword;
1730
2609
  }
2610
+ if (isExplicit("password_reject_breached")) {
2611
+ patch.plugins = {
2612
+ ...patch.plugins ?? {},
2613
+ passwordRejectBreached: asBoolean(values.password_reject_breached, false)
2614
+ };
2615
+ }
2616
+ if (isExplicit("password_require_complexity")) {
2617
+ patch.passwordRequireComplexity = asBoolean(values.password_require_complexity, false);
2618
+ }
2619
+ if (isExplicit("password_min_classes")) {
2620
+ const n = asPositiveInt(values.password_min_classes);
2621
+ if (n !== void 0) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
2622
+ }
2623
+ if (isExplicit("password_history_count")) {
2624
+ const n = Math.floor(Number(values.password_history_count));
2625
+ if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
2626
+ }
1731
2627
  const session = {};
1732
2628
  if (isExplicit("session_expiry_days")) {
1733
2629
  const d = asPositiveInt(values.session_expiry_days);
@@ -1740,6 +2636,33 @@ var AuthPlugin = class {
1740
2636
  if (Object.keys(session).length > 0) {
1741
2637
  patch.session = session;
1742
2638
  }
2639
+ const asNonNegativeInt = (value) => {
2640
+ const n = Math.floor(Number(value));
2641
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
2642
+ };
2643
+ if (isExplicit("lockout_threshold")) {
2644
+ const n = asNonNegativeInt(values.lockout_threshold);
2645
+ if (n !== void 0) patch.lockoutThreshold = n;
2646
+ }
2647
+ if (isExplicit("lockout_duration_minutes")) {
2648
+ const n = asPositiveInt(values.lockout_duration_minutes);
2649
+ if (n !== void 0) patch.lockoutDurationMinutes = n;
2650
+ }
2651
+ if (isExplicit("rate_limit_max") || isExplicit("rate_limit_window_seconds")) {
2652
+ const max = asPositiveInt(values.rate_limit_max) ?? 10;
2653
+ const window = asPositiveInt(values.rate_limit_window_seconds) ?? 60;
2654
+ patch.rateLimit = {
2655
+ enabled: true,
2656
+ window,
2657
+ max,
2658
+ customRules: {
2659
+ "/sign-in/email": { window, max },
2660
+ "/sign-up/email": { window, max },
2661
+ "/request-password-reset": { window, max },
2662
+ "/reset-password": { window, max }
2663
+ }
2664
+ };
2665
+ }
1743
2666
  if (isExplicit("google_enabled") || isExplicit("google_client_id") || isExplicit("google_client_secret")) {
1744
2667
  const socialProviders = {
1745
2668
  ...this.configuredSocialProviders ?? {}
@@ -1861,9 +2784,12 @@ var AuthPlugin = class {
1861
2784
  );
1862
2785
  }
1863
2786
  const rawApp = httpServer.getRawApp();
1864
- rawApp.get(`${basePath}/config`, (c) => {
2787
+ rawApp.get(`${basePath}/config`, async (c) => {
1865
2788
  try {
1866
2789
  const config = this.authManager.getPublicConfig();
2790
+ if (config.features?.sso) {
2791
+ config.features.sso = await this.authManager.isSsoUsable();
2792
+ }
1867
2793
  return c.json({ success: true, data: config });
1868
2794
  } catch (error) {
1869
2795
  const err = error instanceof Error ? error : new Error(String(error));
@@ -1949,6 +2875,39 @@ var AuthPlugin = class {
1949
2875
  return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
1950
2876
  }
1951
2877
  });
2878
+ rawApp.post(`${basePath}/admin/unlock-user`, async (c) => {
2879
+ try {
2880
+ let body = {};
2881
+ try {
2882
+ body = await c.req.json();
2883
+ } catch {
2884
+ body = {};
2885
+ }
2886
+ const userId = body?.userId ?? body?.user_id;
2887
+ if (typeof userId !== "string" || userId.length === 0) {
2888
+ return c.json({ success: false, error: { code: "invalid_request", message: "userId is required" } }, 400);
2889
+ }
2890
+ const authApi = await this.authManager.getApi();
2891
+ const session = await authApi.getSession({ headers: c.req.raw.headers });
2892
+ if (!session?.user?.id) {
2893
+ return c.json({ success: false, error: { code: "unauthorized", message: "Sign in first" } }, 401);
2894
+ }
2895
+ const u = session.user;
2896
+ const isAdmin = u?.isPlatformAdmin === true || Array.isArray(u?.roles) && u.roles.includes("platform_admin") || u?.role === "admin";
2897
+ if (!isAdmin) {
2898
+ return c.json({ success: false, error: { code: "forbidden", message: "Admin role required" } }, 403);
2899
+ }
2900
+ const ok = await this.authManager.unlockUser(userId);
2901
+ if (!ok) {
2902
+ return c.json({ success: false, error: { code: "not_found", message: "User not found or data engine unavailable" } }, 404);
2903
+ }
2904
+ return c.json({ success: true, data: { userId } });
2905
+ } catch (error) {
2906
+ const err = error instanceof Error ? error : new Error(String(error));
2907
+ ctx.logger.error("[AuthPlugin] unlock-user failed", err);
2908
+ return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
2909
+ }
2910
+ });
1952
2911
  rawApp.post(`${basePath}/sys-oauth-application/register`, async (c) => {
1953
2912
  try {
1954
2913
  let body = {};
@@ -2094,7 +3053,9 @@ export {
2094
3053
  AUTH_OAUTH_REFRESH_TOKEN_SCHEMA,
2095
3054
  AUTH_ORGANIZATION_SCHEMA,
2096
3055
  AUTH_ORG_SESSION_FIELDS,
3056
+ AUTH_SCIM_PROVIDER_SCHEMA,
2097
3057
  AUTH_SESSION_CONFIG,
3058
+ AUTH_SSO_PROVIDER_SCHEMA,
2098
3059
  AUTH_TEAM_MEMBER_SCHEMA,
2099
3060
  AUTH_TEAM_SCHEMA,
2100
3061
  AUTH_TWO_FACTOR_SCHEMA,
@@ -2113,6 +3074,7 @@ export {
2113
3074
  createObjectQLAdapter,
2114
3075
  createObjectQLAdapterFactory,
2115
3076
  resolveProtocolName,
2116
- runSetInitialPassword
3077
+ runSetInitialPassword,
3078
+ withSystemReadContext
2117
3079
  };
2118
3080
  //# sourceMappingURL=index.mjs.map