@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/LICENSE +202 -93
- package/dist/index.d.mts +284 -3
- package/dist/index.d.ts +284 -3
- package/dist/index.js +1063 -100
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1062 -100
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -5
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,
|
|
@@ -64,7 +66,8 @@ __export(index_exports, {
|
|
|
64
66
|
createObjectQLAdapter: () => createObjectQLAdapter,
|
|
65
67
|
createObjectQLAdapterFactory: () => createObjectQLAdapterFactory,
|
|
66
68
|
resolveProtocolName: () => resolveProtocolName,
|
|
67
|
-
runSetInitialPassword: () => runSetInitialPassword
|
|
69
|
+
runSetInitialPassword: () => runSetInitialPassword,
|
|
70
|
+
withSystemReadContext: () => withSystemReadContext
|
|
68
71
|
});
|
|
69
72
|
module.exports = __toCommonJS(index_exports);
|
|
70
73
|
|
|
@@ -75,6 +78,7 @@ var import_pages = require("@objectstack/platform-objects/pages");
|
|
|
75
78
|
|
|
76
79
|
// src/auth-manager.ts
|
|
77
80
|
var import_types = require("@objectstack/types");
|
|
81
|
+
var import_spec = require("@objectstack/spec");
|
|
78
82
|
|
|
79
83
|
// src/objectql-adapter.ts
|
|
80
84
|
var import_adapters = require("better-auth/adapters");
|
|
@@ -83,7 +87,16 @@ var AUTH_MODEL_TO_PROTOCOL = {
|
|
|
83
87
|
user: import_system.SystemObjectName.USER,
|
|
84
88
|
session: import_system.SystemObjectName.SESSION,
|
|
85
89
|
account: import_system.SystemObjectName.ACCOUNT,
|
|
86
|
-
verification: import_system.SystemObjectName.VERIFICATION
|
|
90
|
+
verification: import_system.SystemObjectName.VERIFICATION,
|
|
91
|
+
// Plugin models. `@better-auth/sso` and `@better-auth/scim` both hardcode
|
|
92
|
+
// their model name and accept NO `schema` option (verified vs 1.6.2x — no
|
|
93
|
+
// mergeSchema, runtime never reads options.schema), so the table name is
|
|
94
|
+
// bridged here and `createObjectQLAdapterFactory` (below) auto-maps their
|
|
95
|
+
// camelCase fields to snake_case (oidcConfig→oidc_config, scimToken→
|
|
96
|
+
// scim_token, …) on every CRUD op via resolveProtocolName. Off by default
|
|
97
|
+
// (OS_SSO_ENABLED / OS_SCIM_ENABLED). See ADR-0024 / ADR-0071.
|
|
98
|
+
ssoProvider: "sys_sso_provider",
|
|
99
|
+
scimProvider: "sys_scim_provider"
|
|
87
100
|
};
|
|
88
101
|
function resolveProtocolName(model) {
|
|
89
102
|
return AUTH_MODEL_TO_PROTOCOL[model] ?? model;
|
|
@@ -147,7 +160,28 @@ function convertWhere(where) {
|
|
|
147
160
|
}
|
|
148
161
|
return filter;
|
|
149
162
|
}
|
|
150
|
-
function
|
|
163
|
+
function withSystemReadContext(engine) {
|
|
164
|
+
const e = engine;
|
|
165
|
+
const asSystem = (q) => ({ ...q ?? {}, context: { isSystem: true, ...q?.context ?? {} } });
|
|
166
|
+
return {
|
|
167
|
+
insert: (m, d) => e.insert(m, d),
|
|
168
|
+
update: (m, d) => e.update(m, d),
|
|
169
|
+
delete: (m, q) => e.delete(m, q),
|
|
170
|
+
find: (m, q) => e.find(m, asSystem(q)),
|
|
171
|
+
findOne: (m, q) => e.findOne(m, asSystem(q)),
|
|
172
|
+
count: (m, q) => e.count(m, asSystem(q))
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function createObjectQLAdapterFactory(rawDataEngine) {
|
|
176
|
+
const dataEngine = withSystemReadContext(rawDataEngine);
|
|
177
|
+
const camelToSnake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
178
|
+
const snakeToCamel = (s) => s.replace(/_([a-z])/g, (_m, c) => c.toUpperCase());
|
|
179
|
+
const remapKeys = (obj, fn) => {
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const k of Object.keys(obj)) out[fn(k)] = obj[k];
|
|
182
|
+
return out;
|
|
183
|
+
};
|
|
184
|
+
const remapWhere = (where) => where.map((c) => ({ ...c, field: camelToSnake(c.field) }));
|
|
151
185
|
return (0, import_adapters.createAdapterFactory)({
|
|
152
186
|
config: {
|
|
153
187
|
adapterId: "objectql",
|
|
@@ -162,62 +196,90 @@ function createObjectQLAdapterFactory(dataEngine) {
|
|
|
162
196
|
},
|
|
163
197
|
adapter: () => ({
|
|
164
198
|
create: async ({ model, data, select: _select }) => {
|
|
165
|
-
const
|
|
166
|
-
|
|
199
|
+
const objectName = resolveProtocolName(model);
|
|
200
|
+
const bridged = objectName !== model;
|
|
201
|
+
const result = await dataEngine.insert(objectName, bridged ? remapKeys(data, camelToSnake) : data);
|
|
202
|
+
const norm = normaliseLegacyDates(model, result);
|
|
203
|
+
return bridged ? remapKeys(norm, snakeToCamel) : norm;
|
|
167
204
|
},
|
|
168
205
|
findOne: async ({ model, where, select, join: _join }) => {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
206
|
+
const objectName = resolveProtocolName(model);
|
|
207
|
+
const bridged = objectName !== model;
|
|
208
|
+
const filter = convertWhere(bridged ? remapWhere(where) : where);
|
|
209
|
+
const fields = bridged && select ? select.map(camelToSnake) : select;
|
|
210
|
+
const result = await dataEngine.findOne(objectName, { where: filter, fields });
|
|
211
|
+
if (!result) return null;
|
|
212
|
+
const norm = normaliseLegacyDates(model, result);
|
|
213
|
+
return bridged ? remapKeys(norm, snakeToCamel) : norm;
|
|
172
214
|
},
|
|
173
215
|
findMany: async ({ model, where, limit, offset, sortBy, join: _join }) => {
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
216
|
+
const objectName = resolveProtocolName(model);
|
|
217
|
+
const bridged = objectName !== model;
|
|
218
|
+
const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
|
|
219
|
+
const orderBy = sortBy ? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction }] : void 0;
|
|
220
|
+
const results = await dataEngine.find(objectName, {
|
|
177
221
|
where: filter,
|
|
178
222
|
limit: limit || 100,
|
|
179
223
|
offset,
|
|
180
224
|
orderBy
|
|
181
225
|
});
|
|
182
|
-
return results.map((r) =>
|
|
226
|
+
return results.map((r) => {
|
|
227
|
+
const norm = normaliseLegacyDates(model, r);
|
|
228
|
+
return bridged ? remapKeys(norm, snakeToCamel) : norm;
|
|
229
|
+
});
|
|
183
230
|
},
|
|
184
231
|
count: async ({ model, where }) => {
|
|
185
|
-
const
|
|
186
|
-
|
|
232
|
+
const objectName = resolveProtocolName(model);
|
|
233
|
+
const bridged = objectName !== model;
|
|
234
|
+
const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
|
|
235
|
+
return await dataEngine.count(objectName, { where: filter });
|
|
187
236
|
},
|
|
188
237
|
update: async ({ model, where, update }) => {
|
|
189
|
-
const
|
|
190
|
-
const
|
|
238
|
+
const objectName = resolveProtocolName(model);
|
|
239
|
+
const bridged = objectName !== model;
|
|
240
|
+
const filter = convertWhere(bridged ? remapWhere(where) : where);
|
|
241
|
+
const record = await dataEngine.findOne(objectName, { where: filter });
|
|
191
242
|
if (!record) return null;
|
|
192
|
-
const
|
|
193
|
-
|
|
243
|
+
const patch = bridged ? remapKeys(update, camelToSnake) : update;
|
|
244
|
+
const result = await dataEngine.update(objectName, { ...patch, id: record.id });
|
|
245
|
+
if (!result) return null;
|
|
246
|
+
const norm = normaliseLegacyDates(model, result);
|
|
247
|
+
return bridged ? remapKeys(norm, snakeToCamel) : norm;
|
|
194
248
|
},
|
|
195
249
|
updateMany: async ({ model, where, update }) => {
|
|
196
|
-
const
|
|
197
|
-
const
|
|
250
|
+
const objectName = resolveProtocolName(model);
|
|
251
|
+
const bridged = objectName !== model;
|
|
252
|
+
const filter = convertWhere(bridged ? remapWhere(where) : where);
|
|
253
|
+
const records = await dataEngine.find(objectName, { where: filter });
|
|
254
|
+
const patch = bridged ? remapKeys(update, camelToSnake) : update;
|
|
198
255
|
for (const record of records) {
|
|
199
|
-
await dataEngine.update(
|
|
256
|
+
await dataEngine.update(objectName, { ...patch, id: record.id });
|
|
200
257
|
}
|
|
201
258
|
return records.length;
|
|
202
259
|
},
|
|
203
260
|
delete: async ({ model, where }) => {
|
|
204
|
-
const
|
|
205
|
-
const
|
|
261
|
+
const objectName = resolveProtocolName(model);
|
|
262
|
+
const bridged = objectName !== model;
|
|
263
|
+
const filter = convertWhere(bridged ? remapWhere(where) : where);
|
|
264
|
+
const record = await dataEngine.findOne(objectName, { where: filter });
|
|
206
265
|
if (!record) return;
|
|
207
|
-
await dataEngine.delete(
|
|
266
|
+
await dataEngine.delete(objectName, { where: { id: record.id } });
|
|
208
267
|
},
|
|
209
268
|
deleteMany: async ({ model, where }) => {
|
|
210
|
-
const
|
|
211
|
-
const
|
|
269
|
+
const objectName = resolveProtocolName(model);
|
|
270
|
+
const bridged = objectName !== model;
|
|
271
|
+
const filter = convertWhere(bridged ? remapWhere(where) : where);
|
|
272
|
+
const records = await dataEngine.find(objectName, { where: filter });
|
|
212
273
|
for (const record of records) {
|
|
213
|
-
await dataEngine.delete(
|
|
274
|
+
await dataEngine.delete(objectName, { where: { id: record.id } });
|
|
214
275
|
}
|
|
215
276
|
return records.length;
|
|
216
277
|
}
|
|
217
278
|
})
|
|
218
279
|
});
|
|
219
280
|
}
|
|
220
|
-
function createObjectQLAdapter(
|
|
281
|
+
function createObjectQLAdapter(rawDataEngine) {
|
|
282
|
+
const dataEngine = withSystemReadContext(rawDataEngine);
|
|
221
283
|
return {
|
|
222
284
|
create: async ({ model, data, select: _select }) => {
|
|
223
285
|
const objectName = resolveProtocolName(model);
|
|
@@ -523,6 +585,25 @@ function buildOauthProviderPluginSchema() {
|
|
|
523
585
|
};
|
|
524
586
|
}
|
|
525
587
|
var buildOidcProviderPluginSchema = buildOauthProviderPluginSchema;
|
|
588
|
+
var AUTH_SSO_PROVIDER_SCHEMA = {
|
|
589
|
+
modelName: "sys_sso_provider",
|
|
590
|
+
fields: {
|
|
591
|
+
providerId: "provider_id",
|
|
592
|
+
oidcConfig: "oidc_config",
|
|
593
|
+
samlConfig: "saml_config",
|
|
594
|
+
userId: "user_id",
|
|
595
|
+
organizationId: "organization_id"
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
var AUTH_SCIM_PROVIDER_SCHEMA = {
|
|
599
|
+
modelName: "sys_scim_provider",
|
|
600
|
+
fields: {
|
|
601
|
+
providerId: "provider_id",
|
|
602
|
+
scimToken: "scim_token",
|
|
603
|
+
organizationId: "organization_id",
|
|
604
|
+
userId: "user_id"
|
|
605
|
+
}
|
|
606
|
+
};
|
|
526
607
|
function buildDeviceAuthorizationPluginSchema() {
|
|
527
608
|
return {
|
|
528
609
|
deviceCode: AUTH_DEVICE_CODE_SCHEMA
|
|
@@ -587,6 +668,9 @@ function readDisableSignUpEnv() {
|
|
|
587
668
|
if (signupEnabled != null) return !signupEnabled;
|
|
588
669
|
return readBooleanEnv("OS_DISABLE_SIGNUP");
|
|
589
670
|
}
|
|
671
|
+
function readSsoOnlyEnv() {
|
|
672
|
+
return readBooleanEnv("OS_AUTH_SSO_ONLY");
|
|
673
|
+
}
|
|
590
674
|
var AuthManager = class {
|
|
591
675
|
constructor(config) {
|
|
592
676
|
this.auth = null;
|
|
@@ -626,6 +710,14 @@ var AuthManager = class {
|
|
|
626
710
|
// createAdapterFactory.
|
|
627
711
|
user: {
|
|
628
712
|
...AUTH_USER_CONFIG
|
|
713
|
+
// NOTE: the env-side AI-seat marker `sys_user.ai_access` is deliberately
|
|
714
|
+
// NOT declared as a better-auth additionalField. sys_user is a
|
|
715
|
+
// better-auth-MANAGED table and better-auth SELECTs explicit columns, so
|
|
716
|
+
// declaring it here would make getSession query a column that may not
|
|
717
|
+
// exist on every env yet → broken auth. Instead the column is owned by
|
|
718
|
+
// the objectql `SysUser` object def (provisioned by boot schema-sync)
|
|
719
|
+
// and read by a GUARDED system query in resolveCtx (can only no-op,
|
|
720
|
+
// never break auth). better-auth stays oblivious to the extra column.
|
|
629
721
|
},
|
|
630
722
|
account: {
|
|
631
723
|
...AUTH_ACCOUNT_CONFIG,
|
|
@@ -674,7 +766,7 @@ var AuthManager = class {
|
|
|
674
766
|
// lock the registration policy without relying on UI state.
|
|
675
767
|
emailAndPassword: (() => {
|
|
676
768
|
const disableSignUpFromEnv = readDisableSignUpEnv();
|
|
677
|
-
const effectiveDisableSignUp = disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
|
|
769
|
+
const effectiveDisableSignUp = this.resolveSsoOnly() ? true : disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
|
|
678
770
|
return {
|
|
679
771
|
enabled: this.config.emailAndPassword?.enabled ?? true,
|
|
680
772
|
...passwordHasher ? { password: passwordHasher } : {},
|
|
@@ -688,28 +780,28 @@ var AuthManager = class {
|
|
|
688
780
|
sendResetPassword: async ({ user, url, token }) => {
|
|
689
781
|
const email = this.getEmailService();
|
|
690
782
|
if (!email) {
|
|
691
|
-
|
|
692
|
-
`
|
|
783
|
+
throw new Error(
|
|
784
|
+
`Password-reset email could not be sent to ${user.email}: no email service is configured for this deployment.`
|
|
693
785
|
);
|
|
694
|
-
return;
|
|
695
786
|
}
|
|
696
787
|
const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
788
|
+
const result = await email.sendTemplate({
|
|
789
|
+
template: "auth.password_reset",
|
|
790
|
+
to: { address: user.email, ...user.name ? { name: user.name } : {} },
|
|
791
|
+
data: {
|
|
792
|
+
user: { name: user.name || user.email, email: user.email, id: user.id },
|
|
793
|
+
resetUrl: url,
|
|
794
|
+
token,
|
|
795
|
+
expiresInMinutes: Math.round(ttlSec / 60),
|
|
796
|
+
appName: this.getAppName()
|
|
797
|
+
},
|
|
798
|
+
relatedObject: "sys_user",
|
|
799
|
+
relatedId: user.id
|
|
800
|
+
});
|
|
801
|
+
if (result?.status === "failed") {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`Password-reset email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
|
|
804
|
+
);
|
|
713
805
|
}
|
|
714
806
|
}
|
|
715
807
|
};
|
|
@@ -724,28 +816,28 @@ var AuthManager = class {
|
|
|
724
816
|
sendVerificationEmail: async ({ user, url, token }) => {
|
|
725
817
|
const email = this.getEmailService();
|
|
726
818
|
if (!email) {
|
|
727
|
-
|
|
728
|
-
`
|
|
819
|
+
throw new Error(
|
|
820
|
+
`Verification email could not be sent to ${user.email}: no email service is configured for this deployment.`
|
|
729
821
|
);
|
|
730
|
-
return;
|
|
731
822
|
}
|
|
732
823
|
const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
824
|
+
const result = await email.sendTemplate({
|
|
825
|
+
template: "auth.verify_email",
|
|
826
|
+
to: { address: user.email, ...user.name ? { name: user.name } : {} },
|
|
827
|
+
data: {
|
|
828
|
+
user: { name: user.name || user.email, email: user.email, id: user.id },
|
|
829
|
+
verificationUrl: url,
|
|
830
|
+
token,
|
|
831
|
+
expiresInMinutes: Math.round(ttlSec / 60),
|
|
832
|
+
appName: this.getAppName()
|
|
833
|
+
},
|
|
834
|
+
relatedObject: "sys_user",
|
|
835
|
+
relatedId: user.id
|
|
836
|
+
});
|
|
837
|
+
if (result?.status === "failed") {
|
|
838
|
+
throw new Error(
|
|
839
|
+
`Verification email could not be sent to ${user.email}: ${result.error ?? "delivery failed"}`
|
|
840
|
+
);
|
|
749
841
|
}
|
|
750
842
|
}
|
|
751
843
|
}
|
|
@@ -758,12 +850,18 @@ var AuthManager = class {
|
|
|
758
850
|
updateAge: this.config.session?.updateAge || 60 * 60 * 24
|
|
759
851
|
// 1 day default
|
|
760
852
|
},
|
|
853
|
+
// ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
|
|
854
|
+
// so better-auth keeps its own defaults otherwise. The settings bind
|
|
855
|
+
// supplies stricter `customRules` for the auth endpoints.
|
|
856
|
+
...this.config.rateLimit ? { rateLimit: this.config.rateLimit } : {},
|
|
761
857
|
// better-auth plugins — registered based on AuthPluginConfig flags
|
|
762
858
|
plugins,
|
|
763
859
|
// Database hooks (fired by better-auth's adapter writes — these run
|
|
764
860
|
// for SSO JIT-provisioning too, unlike kernel-level ObjectQL
|
|
765
|
-
// middleware which better-auth's adapter bypasses).
|
|
766
|
-
|
|
861
|
+
// middleware which better-auth's adapter bypasses). The framework's
|
|
862
|
+
// identity-source stamp (`account.create.after`) is always composed in,
|
|
863
|
+
// preserving any host-supplied hooks.
|
|
864
|
+
databaseHooks: this.composeDatabaseHooks(this.config.databaseHooks),
|
|
767
865
|
// Bootstrap bypass for `disableSignUp`. The first-run owner wizard
|
|
768
866
|
// (`/_account/setup`) calls `POST /auth/sign-up/email` to create
|
|
769
867
|
// the very first user — if `OS_DISABLE_SIGNUP=true` is set on a
|
|
@@ -774,6 +872,126 @@ var AuthManager = class {
|
|
|
774
872
|
// sees `userCount > 0` and the toggle is enforced again.
|
|
775
873
|
hooks: {
|
|
776
874
|
before: createAuthMiddleware(async (ctx) => {
|
|
875
|
+
if (ctx?.path === "/sign-up/email" || ctx?.path === "/reset-password" || ctx?.path === "/change-password") {
|
|
876
|
+
const candidate = typeof ctx?.body?.password === "string" && ctx.body.password || typeof ctx?.body?.newPassword === "string" && ctx.body.newPassword || "";
|
|
877
|
+
if (candidate) await this.assertPasswordComplexity(candidate);
|
|
878
|
+
if (candidate && (ctx?.path === "/reset-password" || ctx?.path === "/change-password")) {
|
|
879
|
+
const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => void 0);
|
|
880
|
+
if (userId) {
|
|
881
|
+
const pw = ctx?.context?.password;
|
|
882
|
+
const verify = typeof pw?.verify === "function" ? pw.verify.bind(pw) : void 0;
|
|
883
|
+
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
|
|
884
|
+
if (oldHash !== void 0) ctx.context.__osPwHistory = { userId, oldHash };
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (ctx?.path === "/sso/register") {
|
|
889
|
+
const actor = await this.resolveActor(ctx);
|
|
890
|
+
if (actor?.userId) {
|
|
891
|
+
const ok = await this.isOrgOrPlatformAdmin(actor.userId, actor.activeOrgId);
|
|
892
|
+
if (!ok) {
|
|
893
|
+
const { APIError } = await import("better-auth/api");
|
|
894
|
+
throw new APIError("FORBIDDEN", {
|
|
895
|
+
message: "Only an organization owner/admin or a platform admin can register an SSO provider.",
|
|
896
|
+
code: "SSO_REGISTER_FORBIDDEN"
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (ctx?.path === "/oauth2/authorize" && this.config.oidcAuthorizeGate) {
|
|
903
|
+
const clientId = ctx?.query?.client_id;
|
|
904
|
+
if (clientId) {
|
|
905
|
+
let gateUserId;
|
|
906
|
+
try {
|
|
907
|
+
const { getSessionFromCtx } = await import("better-auth/api");
|
|
908
|
+
const s = await getSessionFromCtx(ctx);
|
|
909
|
+
gateUserId = s?.user?.id ?? s?.session?.userId;
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
if (!gateUserId) {
|
|
913
|
+
try {
|
|
914
|
+
const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
|
|
915
|
+
let token;
|
|
916
|
+
const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
|
|
917
|
+
if (bm?.[1]) token = bm[1].trim();
|
|
918
|
+
if (!token) {
|
|
919
|
+
const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
|
|
920
|
+
if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
|
|
921
|
+
}
|
|
922
|
+
if (token) {
|
|
923
|
+
const sess = await ctx.context.adapter.findOne({
|
|
924
|
+
model: "session",
|
|
925
|
+
where: [{ field: "token", value: token }]
|
|
926
|
+
});
|
|
927
|
+
const exp = sess?.expiresAt ?? sess?.expires_at;
|
|
928
|
+
if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
|
|
929
|
+
gateUserId = String(sess.userId ?? sess.user_id ?? "") || void 0;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
} catch {
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (gateUserId) {
|
|
936
|
+
const allowed = await this.config.oidcAuthorizeGate({
|
|
937
|
+
userId: gateUserId,
|
|
938
|
+
clientId: String(clientId)
|
|
939
|
+
});
|
|
940
|
+
if (!allowed) {
|
|
941
|
+
const { APIError } = await import("better-auth/api");
|
|
942
|
+
throw new APIError("FORBIDDEN", {
|
|
943
|
+
message: "You are not authorized to sign in to this environment.",
|
|
944
|
+
code: "ENV_ACCESS_DENIED"
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (ctx?.path === "/delete-user" || ctx?.path === "/admin/remove-user" || ctx?.path === "/admin/ban-user") {
|
|
952
|
+
let isLastLocalCredential = false;
|
|
953
|
+
try {
|
|
954
|
+
const adapter = ctx.context.adapter;
|
|
955
|
+
let targetId = ctx?.body?.userId ?? ctx?.body?.user_id;
|
|
956
|
+
if (!targetId && ctx.path === "/delete-user") {
|
|
957
|
+
const { getSessionFromCtx } = await import("better-auth/api");
|
|
958
|
+
const s = await getSessionFromCtx(ctx).catch(() => null);
|
|
959
|
+
targetId = s?.user?.id ?? s?.session?.userId;
|
|
960
|
+
}
|
|
961
|
+
if (targetId) {
|
|
962
|
+
const targetCred = await adapter.findOne({
|
|
963
|
+
model: "account",
|
|
964
|
+
where: [
|
|
965
|
+
{ field: "userId", value: targetId },
|
|
966
|
+
{ field: "providerId", value: "credential" }
|
|
967
|
+
]
|
|
968
|
+
});
|
|
969
|
+
if (targetCred) {
|
|
970
|
+
const creds = await adapter.findMany({
|
|
971
|
+
model: "account",
|
|
972
|
+
where: [{ field: "providerId", value: "credential" }]
|
|
973
|
+
});
|
|
974
|
+
const otherHolders = new Set(
|
|
975
|
+
(creds ?? []).map((a) => a?.userId ?? a?.user_id).filter((id) => id && id !== targetId)
|
|
976
|
+
);
|
|
977
|
+
isLastLocalCredential = otherHolders.size === 0;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
} catch {
|
|
981
|
+
}
|
|
982
|
+
if (isLastLocalCredential) {
|
|
983
|
+
const { APIError } = await import("better-auth/api");
|
|
984
|
+
throw new APIError("CONFLICT", {
|
|
985
|
+
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.",
|
|
986
|
+
code: "LAST_LOCAL_CREDENTIAL"
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (ctx?.path === "/sign-in/email") {
|
|
991
|
+
const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
|
|
992
|
+
if (email) await this.assertAccountNotLocked(email);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
777
995
|
if (ctx?.path !== "/sign-up/email") return;
|
|
778
996
|
const ep = ctx?.context?.options?.emailAndPassword;
|
|
779
997
|
if (!ep?.disableSignUp) return;
|
|
@@ -788,6 +1006,35 @@ var AuthManager = class {
|
|
|
788
1006
|
}
|
|
789
1007
|
}),
|
|
790
1008
|
after: createAuthMiddleware(async (ctx) => {
|
|
1009
|
+
if (ctx?.path === "/sign-in/email") {
|
|
1010
|
+
const email = typeof ctx?.body?.email === "string" ? ctx.body.email : "";
|
|
1011
|
+
if (email) {
|
|
1012
|
+
let succeeded = true;
|
|
1013
|
+
try {
|
|
1014
|
+
const { isAPIError } = await import("better-auth/api");
|
|
1015
|
+
succeeded = !isAPIError(ctx?.context?.returned);
|
|
1016
|
+
} catch {
|
|
1017
|
+
succeeded = !(ctx?.context?.returned instanceof Error);
|
|
1018
|
+
}
|
|
1019
|
+
await this.recordSignInOutcome(email, succeeded);
|
|
1020
|
+
}
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (ctx?.path === "/change-password" || ctx?.path === "/reset-password") {
|
|
1024
|
+
const stash = ctx?.context?.__osPwHistory;
|
|
1025
|
+
if (stash?.userId) {
|
|
1026
|
+
let succeeded = true;
|
|
1027
|
+
try {
|
|
1028
|
+
const { isAPIError } = await import("better-auth/api");
|
|
1029
|
+
succeeded = !isAPIError(ctx?.context?.returned);
|
|
1030
|
+
} catch {
|
|
1031
|
+
succeeded = !(ctx?.context?.returned instanceof Error);
|
|
1032
|
+
}
|
|
1033
|
+
if (succeeded) await this.recordPasswordHistory(stash.userId, stash.oldHash);
|
|
1034
|
+
delete ctx.context.__osPwHistory;
|
|
1035
|
+
}
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
791
1038
|
if (ctx?.path !== "/sign-up/email") return;
|
|
792
1039
|
const ep = ctx?.context?.options?.emailAndPassword;
|
|
793
1040
|
if (ep && ctx.context.__osDisableSignUpOrig !== void 0) {
|
|
@@ -811,6 +1058,20 @@ var AuthManager = class {
|
|
|
811
1058
|
origins.push("http://*.localhost:*");
|
|
812
1059
|
origins.push("https://*.localhost:*");
|
|
813
1060
|
}
|
|
1061
|
+
if (this.isSsoWired()) {
|
|
1062
|
+
return {
|
|
1063
|
+
trustedOrigins: async (request) => {
|
|
1064
|
+
const base = [...origins];
|
|
1065
|
+
try {
|
|
1066
|
+
for (const o of await this.ssoDiscoveryTrustedOrigins(request)) {
|
|
1067
|
+
if (!base.includes(o)) base.push(o);
|
|
1068
|
+
}
|
|
1069
|
+
} catch {
|
|
1070
|
+
}
|
|
1071
|
+
return base;
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
814
1075
|
return origins.length ? { trustedOrigins: origins } : {};
|
|
815
1076
|
})(),
|
|
816
1077
|
// Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
|
|
@@ -888,15 +1149,24 @@ var AuthManager = class {
|
|
|
888
1149
|
const plugins = [];
|
|
889
1150
|
const oidcEnv = globalThis?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
|
|
890
1151
|
const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === "true" : void 0;
|
|
1152
|
+
const ssoEnv = globalThis?.process?.env?.OS_SSO_ENABLED;
|
|
1153
|
+
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === "true" : void 0;
|
|
1154
|
+
const scimEnv = globalThis?.process?.env?.OS_SCIM_ENABLED;
|
|
1155
|
+
const scimFromEnv = scimEnv != null ? String(scimEnv).toLowerCase() === "true" : void 0;
|
|
1156
|
+
const scimEffective = scimFromEnv ?? pluginConfig.scim ?? false;
|
|
891
1157
|
const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
|
|
1158
|
+
const hibpFromEnv = readBooleanEnv("OS_AUTH_PASSWORD_REJECT_BREACHED");
|
|
892
1159
|
const enabled = {
|
|
893
1160
|
organization: pluginConfig.organization ?? true,
|
|
894
1161
|
twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false,
|
|
1162
|
+
passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false,
|
|
895
1163
|
passkeys: pluginConfig.passkeys ?? false,
|
|
896
1164
|
magicLink: pluginConfig.magicLink ?? false,
|
|
897
1165
|
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
|
|
898
1166
|
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
|
|
899
|
-
admin: pluginConfig.admin ??
|
|
1167
|
+
admin: pluginConfig.admin ?? scimEffective,
|
|
1168
|
+
sso: ssoFromEnv ?? pluginConfig.sso ?? false,
|
|
1169
|
+
scim: scimEffective
|
|
900
1170
|
};
|
|
901
1171
|
const { bearer } = await import("better-auth/plugins/bearer");
|
|
902
1172
|
plugins.push(bearer());
|
|
@@ -939,6 +1209,28 @@ var AuthManager = class {
|
|
|
939
1209
|
// the built-in /accept-invitation route usable for pilots; operators
|
|
940
1210
|
// who wire a real mailer can re-enable downstream.
|
|
941
1211
|
requireEmailVerificationOnInvitation: false,
|
|
1212
|
+
// Cap how many orgs a user can CREATE (OS_ORG_LIMIT). Counts only orgs
|
|
1213
|
+
// the user OWNS (role=owner) — never orgs they were merely invited into —
|
|
1214
|
+
// so a generous cap stops scripted org/free-env spam (each new org can
|
|
1215
|
+
// auto-provision a free environment on the cloud control plane) WITHOUT
|
|
1216
|
+
// ever blocking a collaborator who belongs to many orgs. Unset → no
|
|
1217
|
+
// limit (self-host default). Fail-open: if the count can't be taken we
|
|
1218
|
+
// allow creation rather than block a legitimate user on an infra hiccup.
|
|
1219
|
+
organizationLimit: async (user) => {
|
|
1220
|
+
const limit = (0, import_types.resolveOrgLimit)();
|
|
1221
|
+
if (limit == null) return false;
|
|
1222
|
+
const engine = this.config.dataEngine;
|
|
1223
|
+
const uid = typeof user?.id === "string" ? user.id : "";
|
|
1224
|
+
if (!engine || !uid) return false;
|
|
1225
|
+
try {
|
|
1226
|
+
const owned = await withSystemReadContext(engine).count("sys_member", {
|
|
1227
|
+
where: { user_id: uid, role: "owner" }
|
|
1228
|
+
});
|
|
1229
|
+
return (owned ?? 0) >= limit;
|
|
1230
|
+
} catch {
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
942
1234
|
...customOrgRoles ? { roles: customOrgRoles } : {},
|
|
943
1235
|
// ── Slug-change guard ─────────────────────────────────────
|
|
944
1236
|
// An org's slug is baked into every env hostname at creation
|
|
@@ -961,17 +1253,32 @@ var AuthManager = class {
|
|
|
961
1253
|
// 2. else `OS_MULTI_TENANT` (multi-tenant deployments are always
|
|
962
1254
|
// multi-org), default `'false'` → single-org / per-env runtime.
|
|
963
1255
|
beforeCreateOrganization: async () => {
|
|
964
|
-
|
|
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") {
|
|
1256
|
+
if (!(0, import_types.resolveMultiOrgEnabled)()) {
|
|
969
1257
|
const { APIError } = await import("better-auth/api");
|
|
970
1258
|
throw new APIError("FORBIDDEN", {
|
|
971
1259
|
message: "Creating additional organizations is disabled on this deployment."
|
|
972
1260
|
});
|
|
973
1261
|
}
|
|
974
1262
|
},
|
|
1263
|
+
// Run host-provided org-creation side effects (e.g. the cloud control
|
|
1264
|
+
// plane provisions the org's born-with production environment). The
|
|
1265
|
+
// org-plugin's models don't fire core databaseHooks, so this is the
|
|
1266
|
+
// only server-side seam for "every org is born with its prod env".
|
|
1267
|
+
// Failure-isolated: org creation must not roll back on a side-effect miss.
|
|
1268
|
+
afterCreateOrganization: async ({ organization: organization2, member, user }) => {
|
|
1269
|
+
const cb = this.config.onOrganizationCreated;
|
|
1270
|
+
if (typeof cb !== "function") return;
|
|
1271
|
+
try {
|
|
1272
|
+
await cb({
|
|
1273
|
+
organizationId: organization2?.id,
|
|
1274
|
+
userId: user?.id ?? member?.userId,
|
|
1275
|
+
name: organization2?.name,
|
|
1276
|
+
slug: organization2?.slug
|
|
1277
|
+
});
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
console.warn("[auth] onOrganizationCreated callback failed:", err?.message ?? String(err));
|
|
1280
|
+
}
|
|
1281
|
+
},
|
|
975
1282
|
beforeUpdateOrganization: async ({ organization: organization2, member }) => {
|
|
976
1283
|
const newSlug = organization2?.slug;
|
|
977
1284
|
const orgId = member?.organizationId;
|
|
@@ -1049,6 +1356,12 @@ var AuthManager = class {
|
|
|
1049
1356
|
schema: buildTwoFactorPluginSchema()
|
|
1050
1357
|
}));
|
|
1051
1358
|
}
|
|
1359
|
+
if (enabled.passwordRejectBreached) {
|
|
1360
|
+
const { haveIBeenPwned } = await import("better-auth/plugins/haveibeenpwned");
|
|
1361
|
+
plugins.push(haveIBeenPwned({
|
|
1362
|
+
customPasswordCompromisedMessage: "This password has appeared in a known data breach. Please choose a different one."
|
|
1363
|
+
}));
|
|
1364
|
+
}
|
|
1052
1365
|
if (enabled.admin) {
|
|
1053
1366
|
const { admin } = await import("better-auth/plugins/admin");
|
|
1054
1367
|
plugins.push(admin({
|
|
@@ -1116,6 +1429,16 @@ var AuthManager = class {
|
|
|
1116
1429
|
schema: buildOauthProviderPluginSchema()
|
|
1117
1430
|
}));
|
|
1118
1431
|
}
|
|
1432
|
+
if (enabled.sso) {
|
|
1433
|
+
const { sso } = await import("@better-auth/sso");
|
|
1434
|
+
plugins.push(sso({
|
|
1435
|
+
organizationProvisioning: { defaultRole: "member" }
|
|
1436
|
+
}));
|
|
1437
|
+
}
|
|
1438
|
+
if (enabled.scim) {
|
|
1439
|
+
const { scim } = await import("@better-auth/scim");
|
|
1440
|
+
plugins.push(scim({ storeSCIMToken: "hashed" }));
|
|
1441
|
+
}
|
|
1119
1442
|
if (enabled.deviceAuthorization) {
|
|
1120
1443
|
const { deviceAuthorization } = await import("better-auth/plugins/device-authorization");
|
|
1121
1444
|
const baseUrl = (this.config.baseUrl ?? "").replace(/\/$/, "");
|
|
@@ -1152,30 +1475,36 @@ var AuthManager = class {
|
|
|
1152
1475
|
return false;
|
|
1153
1476
|
}
|
|
1154
1477
|
};
|
|
1155
|
-
const
|
|
1478
|
+
const activeOrgRoles = async () => {
|
|
1156
1479
|
try {
|
|
1157
1480
|
const orgId = session?.activeOrganizationId;
|
|
1158
|
-
if (!orgId) return
|
|
1481
|
+
if (!orgId) return [];
|
|
1159
1482
|
const members = await dataEngine.find("sys_member", {
|
|
1160
1483
|
where: { user_id: user.id, organization_id: orgId },
|
|
1161
1484
|
limit: 5
|
|
1162
1485
|
});
|
|
1163
|
-
|
|
1486
|
+
const out = [];
|
|
1487
|
+
for (const m of Array.isArray(members) ? members : []) {
|
|
1164
1488
|
const raw = typeof m?.role === "string" ? m.role : "";
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1489
|
+
for (const r of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
1490
|
+
const mapped = (0, import_spec.mapMembershipRole)(r);
|
|
1491
|
+
if (!out.includes(mapped)) out.push(mapped);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return out;
|
|
1168
1495
|
} catch {
|
|
1169
|
-
return
|
|
1496
|
+
return [];
|
|
1170
1497
|
}
|
|
1171
1498
|
};
|
|
1172
1499
|
const platformAdmin = await isPlatformAdmin();
|
|
1173
|
-
const
|
|
1500
|
+
const orgRoles = await activeOrgRoles();
|
|
1174
1501
|
const storedRole = typeof user.role === "string" ? user.role : "";
|
|
1175
|
-
const roles =
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1502
|
+
const roles = Array.from(/* @__PURE__ */ new Set([
|
|
1503
|
+
...storedRole.split(",").map((s) => s.trim()).filter(Boolean),
|
|
1504
|
+
...orgRoles,
|
|
1505
|
+
...platformAdmin ? [import_spec.BUILTIN_ROLE_PLATFORM_ADMIN] : []
|
|
1506
|
+
]));
|
|
1507
|
+
return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
|
|
1179
1508
|
}));
|
|
1180
1509
|
}
|
|
1181
1510
|
return plugins;
|
|
@@ -1374,6 +1703,18 @@ var AuthManager = class {
|
|
|
1374
1703
|
// `sys_device_code`. Enable via `plugins.deviceAuthorization: true` in
|
|
1375
1704
|
// AuthPluginConfig.
|
|
1376
1705
|
// ---------------------------------------------------------------------------
|
|
1706
|
+
/**
|
|
1707
|
+
* SSO-only ("enforced") login mode: the login UI hides the local password
|
|
1708
|
+
* form + self-registration so the team signs in via the IdP only.
|
|
1709
|
+
* `OS_AUTH_SSO_ONLY` (when set) wins over the `ssoOnlyMode` config knob —
|
|
1710
|
+
* parity with the `disableSignUp` env override — so a deployment can force
|
|
1711
|
+
* it regardless of the per-env/config value. Break-glass is preserved: this
|
|
1712
|
+
* NEVER disables `emailAndPassword.enabled`; it only forces `disableSignUp`
|
|
1713
|
+
* and signals the UI to hide the password form. Generic over the IdP.
|
|
1714
|
+
*/
|
|
1715
|
+
resolveSsoOnly() {
|
|
1716
|
+
return readSsoOnlyEnv() ?? (this.config.ssoOnlyMode ?? false);
|
|
1717
|
+
}
|
|
1377
1718
|
getPublicConfig() {
|
|
1378
1719
|
const socialProviders = [];
|
|
1379
1720
|
if (this.config.socialProviders) {
|
|
@@ -1411,16 +1752,15 @@ var AuthManager = class {
|
|
|
1411
1752
|
}
|
|
1412
1753
|
const emailPasswordConfig = this.config.emailAndPassword ?? {};
|
|
1413
1754
|
const disableSignUpFromEnv = readDisableSignUpEnv();
|
|
1755
|
+
const ssoOnly = this.resolveSsoOnly();
|
|
1414
1756
|
const emailPassword = {
|
|
1415
1757
|
enabled: emailPasswordConfig.enabled !== false,
|
|
1416
1758
|
// Default to true
|
|
1417
|
-
disableSignUp: disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
|
|
1759
|
+
disableSignUp: ssoOnly ? true : disableSignUpFromEnv ?? emailPasswordConfig.disableSignUp ?? false,
|
|
1418
1760
|
requireEmailVerification: emailPasswordConfig.requireEmailVerification ?? false
|
|
1419
1761
|
};
|
|
1420
1762
|
const pluginConfig = this.config.plugins ?? {};
|
|
1421
|
-
const
|
|
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";
|
|
1763
|
+
const multiOrgEnabled = (0, import_types.resolveMultiOrgEnabled)();
|
|
1424
1764
|
const DEFAULT_TERMS_URL = "https://objectstack.ai/terms";
|
|
1425
1765
|
const DEFAULT_PRIVACY_URL = "https://objectstack.ai/privacy";
|
|
1426
1766
|
const rawTermsUrl = globalThis?.process?.env?.OS_TERMS_URL;
|
|
@@ -1443,6 +1783,15 @@ var AuthManager = class {
|
|
|
1443
1783
|
organization: pluginConfig.organization ?? true,
|
|
1444
1784
|
multiOrgEnabled,
|
|
1445
1785
|
oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
|
|
1786
|
+
// Coarse "is the @better-auth/sso plugin wired" flag. The `/auth/config`
|
|
1787
|
+
// route refines this to "usable" (≥1 provider configured) via
|
|
1788
|
+
// `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button
|
|
1789
|
+
// both when SSO is off AND when it's on but no IdP exists yet.
|
|
1790
|
+
sso: this.isSsoWired(),
|
|
1791
|
+
// SSO-only ("enforced"): tell the login UI to hide the local password
|
|
1792
|
+
// form + self-registration. A break-glass "use a password" link remains
|
|
1793
|
+
// for the env owner / local admin. Driven by `ssoOnlyMode` / `OS_AUTH_SSO_ONLY`.
|
|
1794
|
+
ssoEnforced: ssoOnly,
|
|
1446
1795
|
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
|
|
1447
1796
|
admin: pluginConfig.admin ?? false,
|
|
1448
1797
|
...termsUrl ? { termsUrl } : {},
|
|
@@ -1454,6 +1803,493 @@ var AuthManager = class {
|
|
|
1454
1803
|
features
|
|
1455
1804
|
};
|
|
1456
1805
|
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag.
|
|
1808
|
+
* Resolved with the EXACT logic that decides whether the plugin is mounted
|
|
1809
|
+
* in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
|
|
1810
|
+
* advertised capability can never disagree with the actual `/sign-in/sso`
|
|
1811
|
+
* route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
|
|
1812
|
+
*/
|
|
1813
|
+
isSsoWired() {
|
|
1814
|
+
const ssoEnv = globalThis?.process?.env?.OS_SSO_ENABLED;
|
|
1815
|
+
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === "true" : void 0;
|
|
1816
|
+
return ssoFromEnv ?? this.config.plugins?.sso ?? false;
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Whether enterprise SSO is actually *usable*, not merely wired: the plugin
|
|
1820
|
+
* is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
|
|
1821
|
+
* matching still happens at `/sign-in/sso`; this answers the coarser "is
|
|
1822
|
+
* there any point showing the SSO button at all", so a freshly-enabled but
|
|
1823
|
+
* unconfigured SSO setup doesn't advertise a button that errors for everyone.
|
|
1824
|
+
*
|
|
1825
|
+
* Fails OPEN to the wired flag when providers can't be counted (no data
|
|
1826
|
+
* engine, query error) — a config-introspection hiccup must never make the
|
|
1827
|
+
* login page hide a button that genuinely works.
|
|
1828
|
+
*/
|
|
1829
|
+
async isSsoUsable() {
|
|
1830
|
+
if (!this.isSsoWired()) return false;
|
|
1831
|
+
const engine = this.getDataEngine();
|
|
1832
|
+
if (!engine) return true;
|
|
1833
|
+
try {
|
|
1834
|
+
const count = await withSystemReadContext(engine).count("sys_sso_provider");
|
|
1835
|
+
return typeof count === "number" ? count > 0 : true;
|
|
1836
|
+
} catch {
|
|
1837
|
+
return true;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Extra `trustedOrigins` entries derived from an external-SSO registration
|
|
1842
|
+
* request. For a `POST /sso/register` | `/sso/update-provider`, parse the
|
|
1843
|
+
* (cloned) body and return the PUBLIC-ROUTABLE origins of the declared
|
|
1844
|
+
* `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery
|
|
1845
|
+
* validation accepts a customer IdP registered at runtime (ADR-0024) without
|
|
1846
|
+
* the operator pre-listing it in boot config. Only public-routable hosts are
|
|
1847
|
+
* returned — private / internal / loopback hosts are never auto-trusted
|
|
1848
|
+
* (better-auth's `isPublicRoutableHost`, the same predicate its own
|
|
1849
|
+
* sub-endpoint check uses). Best-effort: any parse error yields `[]`.
|
|
1850
|
+
*/
|
|
1851
|
+
async ssoDiscoveryTrustedOrigins(request) {
|
|
1852
|
+
try {
|
|
1853
|
+
const req = request;
|
|
1854
|
+
if (!req || typeof req.clone !== "function" || !req.url) return [];
|
|
1855
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") return [];
|
|
1856
|
+
const path = new URL(req.url).pathname;
|
|
1857
|
+
if (!/\/sso\/(register|update-provider)$/.test(path)) return [];
|
|
1858
|
+
const body = await req.clone().json().catch(() => null);
|
|
1859
|
+
if (!body || typeof body !== "object") return [];
|
|
1860
|
+
const oidc = body.oidcConfig ?? {};
|
|
1861
|
+
const candidates = [
|
|
1862
|
+
body.issuer,
|
|
1863
|
+
oidc.discoveryEndpoint,
|
|
1864
|
+
oidc.authorizationEndpoint,
|
|
1865
|
+
oidc.tokenEndpoint,
|
|
1866
|
+
oidc.jwksEndpoint,
|
|
1867
|
+
oidc.userInfoEndpoint
|
|
1868
|
+
].filter((v) => typeof v === "string" && v.length > 0);
|
|
1869
|
+
if (!candidates.length) return [];
|
|
1870
|
+
const { isPublicRoutableHost } = await import("@better-auth/core/utils/host");
|
|
1871
|
+
const out = [];
|
|
1872
|
+
for (const c of candidates) {
|
|
1873
|
+
try {
|
|
1874
|
+
const u = new URL(c);
|
|
1875
|
+
if (isPublicRoutableHost(u.hostname) && !out.includes(u.origin)) out.push(u.origin);
|
|
1876
|
+
} catch {
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return out;
|
|
1880
|
+
} catch {
|
|
1881
|
+
return [];
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Resolve the acting user (+ their active org) for a before-hook gate,
|
|
1886
|
+
* hook-order-independent. Tries the standard cookie session first, then falls
|
|
1887
|
+
* back to explicit token resolution (bearer or the session cookie's token
|
|
1888
|
+
* part) — the bearer plugin may convert `Authorization: Bearer` to a session
|
|
1889
|
+
* AFTER this global before-hook runs. Returns `null` when no valid session
|
|
1890
|
+
* can be resolved (→ caller lets `sessionMiddleware` issue the 401).
|
|
1891
|
+
*/
|
|
1892
|
+
async resolveActor(ctx) {
|
|
1893
|
+
try {
|
|
1894
|
+
const { getSessionFromCtx } = await import("better-auth/api");
|
|
1895
|
+
const s = await getSessionFromCtx(ctx);
|
|
1896
|
+
const userId = s?.user?.id ?? s?.session?.userId;
|
|
1897
|
+
if (userId) {
|
|
1898
|
+
return {
|
|
1899
|
+
userId: String(userId),
|
|
1900
|
+
activeOrgId: s?.session?.activeOrganizationId ?? s?.activeOrganizationId ?? void 0
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
} catch {
|
|
1904
|
+
}
|
|
1905
|
+
try {
|
|
1906
|
+
const hdr = (k) => (ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) || "";
|
|
1907
|
+
let token;
|
|
1908
|
+
const bm = /^Bearer\s+(.+)$/i.exec(hdr("authorization"));
|
|
1909
|
+
if (bm?.[1]) token = bm[1].trim();
|
|
1910
|
+
if (!token) {
|
|
1911
|
+
const cm = /(?:^|;\s*)(?:__Secure-|__Host-)?better-auth\.session_token=([^;]+)/.exec(hdr("cookie"));
|
|
1912
|
+
if (cm?.[1]) token = decodeURIComponent(cm[1]).split(".")[0];
|
|
1913
|
+
}
|
|
1914
|
+
if (token) {
|
|
1915
|
+
const sess = await ctx.context.adapter.findOne({
|
|
1916
|
+
model: "session",
|
|
1917
|
+
where: [{ field: "token", value: token }]
|
|
1918
|
+
});
|
|
1919
|
+
const exp = sess?.expiresAt ?? sess?.expires_at;
|
|
1920
|
+
if (sess && (!exp || new Date(exp).getTime() > Date.now())) {
|
|
1921
|
+
const userId = String(sess.userId ?? sess.user_id ?? "");
|
|
1922
|
+
if (userId) {
|
|
1923
|
+
return {
|
|
1924
|
+
userId,
|
|
1925
|
+
activeOrgId: sess.activeOrganizationId ?? sess.active_organization_id ?? void 0
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
} catch {
|
|
1931
|
+
}
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* True when `userId` is a platform admin (a `sys_user_permission_set` row
|
|
1936
|
+
* pointing at `admin_full_access` with `organization_id = null`) OR an
|
|
1937
|
+
* owner/admin member of `activeOrgId` (any org membership with role
|
|
1938
|
+
* owner/admin when no active org is set). Mirrors the role-derivation in
|
|
1939
|
+
* `customSession`; reads through `withSystemReadContext` so the lookups are
|
|
1940
|
+
* not themselves RLS-scoped to the acting (possibly non-privileged) user.
|
|
1941
|
+
* Fails CLOSED (returns false) on any lookup error — this backs a security
|
|
1942
|
+
* gate, so an unverifiable actor must never pass.
|
|
1943
|
+
*/
|
|
1944
|
+
async isOrgOrPlatformAdmin(userId, activeOrgId) {
|
|
1945
|
+
const engine = this.getDataEngine();
|
|
1946
|
+
if (!engine) return false;
|
|
1947
|
+
const sys = withSystemReadContext(engine);
|
|
1948
|
+
try {
|
|
1949
|
+
const links = await sys.find("sys_user_permission_set", {
|
|
1950
|
+
where: { user_id: userId },
|
|
1951
|
+
limit: 50
|
|
1952
|
+
});
|
|
1953
|
+
const platformLinks = (Array.isArray(links) ? links : []).filter(
|
|
1954
|
+
(l) => !l.organization_id
|
|
1955
|
+
);
|
|
1956
|
+
if (platformLinks.length) {
|
|
1957
|
+
const sets = await sys.find("sys_permission_set", { limit: 50 });
|
|
1958
|
+
const adminSet = (Array.isArray(sets) ? sets : []).find(
|
|
1959
|
+
(r) => r.name === "admin_full_access"
|
|
1960
|
+
);
|
|
1961
|
+
if (adminSet && platformLinks.some((l) => l.permission_set_id === adminSet.id)) {
|
|
1962
|
+
return true;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
const where = { user_id: userId };
|
|
1966
|
+
if (activeOrgId) where.organization_id = activeOrgId;
|
|
1967
|
+
const members = await sys.find("sys_member", { where, limit: 10 });
|
|
1968
|
+
for (const m of Array.isArray(members) ? members : []) {
|
|
1969
|
+
const raw = typeof m?.role === "string" ? m.role : "";
|
|
1970
|
+
if (raw.split(",").map((s) => s.trim()).some((r) => r === "owner" || r === "admin")) {
|
|
1971
|
+
return true;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
return false;
|
|
1975
|
+
} catch {
|
|
1976
|
+
return false;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Compose the framework's identity-source stamp (`account.create.after`)
|
|
1981
|
+
* with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes
|
|
1982
|
+
* `user.create.after` (personal-org provisioning) + `session.create.before`
|
|
1983
|
+
* (active-org) — different model/op, so no collision — but if a host ever
|
|
1984
|
+
* adds its own `account.create.after` we chain it after the stamp rather
|
|
1985
|
+
* than silently dropping one.
|
|
1986
|
+
*/
|
|
1987
|
+
composeDatabaseHooks(host) {
|
|
1988
|
+
const stamp = (account, ctx) => this.stampIdentitySource(account, ctx);
|
|
1989
|
+
const hostAccountAfter = host?.account?.create?.after;
|
|
1990
|
+
const after = hostAccountAfter ? async (account, ctx) => {
|
|
1991
|
+
await stamp(account, ctx);
|
|
1992
|
+
return hostAccountAfter(account, ctx);
|
|
1993
|
+
} : stamp;
|
|
1994
|
+
return {
|
|
1995
|
+
...host ?? {},
|
|
1996
|
+
account: {
|
|
1997
|
+
...host?.account ?? {},
|
|
1998
|
+
create: {
|
|
1999
|
+
...host?.account?.create ?? {},
|
|
2000
|
+
after
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Maintain `sys_user.source` (ADR-0024 D4 provenance) as accounts are linked.
|
|
2007
|
+
* Drives the managed-vs-native user-mgmt gating: a managed (`idp-provisioned`)
|
|
2008
|
+
* user holds no local credential, so the password / identity-edit actions
|
|
2009
|
+
* hide for them — preventing a managed user from self-minting a local
|
|
2010
|
+
* password that would bypass enforced SSO.
|
|
2011
|
+
*
|
|
2012
|
+
* Two cases, both break-glass safe and idempotent (only writes on a real
|
|
2013
|
+
* change, so trackHistory stays quiet):
|
|
2014
|
+
*
|
|
2015
|
+
* • A **federated** account (any non-`credential` provider — the cloud-as-IdP
|
|
2016
|
+
* `objectstack-cloud` provider OR a customer's own OIDC/SAML IdP) is
|
|
2017
|
+
* linked AND the user holds NO local credential → mark `idp-provisioned`.
|
|
2018
|
+
* A user who already has a `credential` account (an env-native user who
|
|
2019
|
+
* linked SSO) is left `env-native` — they keep a usable password.
|
|
2020
|
+
*
|
|
2021
|
+
* • A **credential** account is created (local signup, or the break-glass
|
|
2022
|
+
* owner's password set via set-initial-password — which can land AFTER the
|
|
2023
|
+
* first SSO link) → ensure `env-native`. This flips a previously-stamped
|
|
2024
|
+
* owner back, so the break-glass admin never loses self-service password
|
|
2025
|
+
* management.
|
|
2026
|
+
*
|
|
2027
|
+
* Best-effort: any failure leaves the prior value (the gate fails open — a
|
|
2028
|
+
* managed user might transiently show a password action that simply errors —
|
|
2029
|
+
* never a hard login failure).
|
|
2030
|
+
*/
|
|
2031
|
+
async stampIdentitySource(account, _ctx) {
|
|
2032
|
+
try {
|
|
2033
|
+
const providerId = account?.providerId ?? account?.provider_id;
|
|
2034
|
+
const userId = account?.userId ?? account?.user_id;
|
|
2035
|
+
if (!userId || !providerId) return;
|
|
2036
|
+
const engine = this.getDataEngine();
|
|
2037
|
+
if (!engine) return;
|
|
2038
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2039
|
+
if (providerId === "credential") {
|
|
2040
|
+
const u = await engine.findOne("sys_user", {
|
|
2041
|
+
filter: { id: userId },
|
|
2042
|
+
fields: ["id", "source"],
|
|
2043
|
+
context: SYSTEM_CTX
|
|
2044
|
+
});
|
|
2045
|
+
if (u && u.source === "idp_provisioned") {
|
|
2046
|
+
await engine.update("sys_user", { id: userId, source: "env_native" }, { context: SYSTEM_CTX });
|
|
2047
|
+
}
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
const credentialCount = await engine.count("sys_account", {
|
|
2051
|
+
filter: { user_id: userId, provider_id: "credential" },
|
|
2052
|
+
context: SYSTEM_CTX
|
|
2053
|
+
});
|
|
2054
|
+
if (typeof credentialCount === "number" && credentialCount > 0) return;
|
|
2055
|
+
await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
|
|
2056
|
+
} catch {
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* ADR-0069 D1 — reject a password that doesn't meet the configured character-
|
|
2061
|
+
* class complexity. No-op when `passwordRequireComplexity` is off. Counts the
|
|
2062
|
+
* four classes (upper / lower / digit / symbol) present and throws
|
|
2063
|
+
* `PASSWORD_POLICY_VIOLATION` when fewer than `passwordMinClasses` are used.
|
|
2064
|
+
*/
|
|
2065
|
+
async assertPasswordComplexity(password) {
|
|
2066
|
+
if (!this.config.passwordRequireComplexity) return;
|
|
2067
|
+
const min = Math.min(4, Math.max(1, Math.floor(Number(this.config.passwordMinClasses) || 3)));
|
|
2068
|
+
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);
|
|
2069
|
+
if (classes < min) {
|
|
2070
|
+
const { APIError } = await import("better-auth/api");
|
|
2071
|
+
throw new APIError("BAD_REQUEST", {
|
|
2072
|
+
message: `Password must include at least ${min} of: uppercase, lowercase, digit, symbol.`,
|
|
2073
|
+
code: "PASSWORD_POLICY_VIOLATION"
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
|
|
2079
|
+
* a string[] of hashes, tolerating null / malformed values.
|
|
2080
|
+
*/
|
|
2081
|
+
parseHashes(raw) {
|
|
2082
|
+
if (typeof raw !== "string" || !raw.trim()) return [];
|
|
2083
|
+
try {
|
|
2084
|
+
const arr = JSON.parse(raw);
|
|
2085
|
+
return Array.isArray(arr) ? arr.filter((h) => typeof h === "string" && !!h) : [];
|
|
2086
|
+
} catch {
|
|
2087
|
+
return [];
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* ADR-0069 D1 — resolve the user whose password is being changed. For
|
|
2092
|
+
* `/change-password` the caller is authenticated (session); for
|
|
2093
|
+
* `/reset-password` the user is carried by the reset token's verification
|
|
2094
|
+
* value (the same lookup better-auth's own handler uses).
|
|
2095
|
+
*/
|
|
2096
|
+
async resolvePasswordChangeUserId(ctx) {
|
|
2097
|
+
if (ctx?.path === "/change-password") {
|
|
2098
|
+
const { getSessionFromCtx } = await import("better-auth/api");
|
|
2099
|
+
const sess = await getSessionFromCtx(ctx).catch(() => null);
|
|
2100
|
+
return sess?.user?.id ?? sess?.session?.userId ?? void 0;
|
|
2101
|
+
}
|
|
2102
|
+
if (ctx?.path === "/reset-password") {
|
|
2103
|
+
const token = typeof ctx?.body?.token === "string" ? ctx.body.token : "";
|
|
2104
|
+
if (!token) return void 0;
|
|
2105
|
+
try {
|
|
2106
|
+
const v = await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`);
|
|
2107
|
+
const raw = v?.value;
|
|
2108
|
+
if (!raw) return void 0;
|
|
2109
|
+
if (typeof raw === "string") {
|
|
2110
|
+
const t = raw.trim();
|
|
2111
|
+
if (t.startsWith("{") || t.startsWith('"')) {
|
|
2112
|
+
try {
|
|
2113
|
+
const o = JSON.parse(t);
|
|
2114
|
+
return (typeof o === "string" ? o : o?.userId) ?? void 0;
|
|
2115
|
+
} catch {
|
|
2116
|
+
return t;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return t;
|
|
2120
|
+
}
|
|
2121
|
+
return raw?.userId ?? void 0;
|
|
2122
|
+
} catch {
|
|
2123
|
+
return void 0;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return void 0;
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's
|
|
2130
|
+
* current password or any hash in the bounded history. Reuses better-auth's
|
|
2131
|
+
* native `password.verify` (passed in) rather than re-hashing. Returns the
|
|
2132
|
+
* current hash (for the after-hook to append) when the candidate is fresh, or
|
|
2133
|
+
* undefined when the feature is off / nothing to compare.
|
|
2134
|
+
*/
|
|
2135
|
+
async assertPasswordNotReused(userId, candidate, verify) {
|
|
2136
|
+
const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
|
|
2137
|
+
if (count <= 0 || typeof verify !== "function") return void 0;
|
|
2138
|
+
const engine = this.getDataEngine();
|
|
2139
|
+
if (!engine) return void 0;
|
|
2140
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2141
|
+
let account;
|
|
2142
|
+
try {
|
|
2143
|
+
account = await engine.findOne("sys_account", {
|
|
2144
|
+
where: { user_id: userId, provider_id: "credential" },
|
|
2145
|
+
fields: ["id", "password", "previous_password_hashes"],
|
|
2146
|
+
context: SYSTEM_CTX
|
|
2147
|
+
});
|
|
2148
|
+
} catch {
|
|
2149
|
+
return void 0;
|
|
2150
|
+
}
|
|
2151
|
+
if (!account?.id) return void 0;
|
|
2152
|
+
const currentHash = typeof account.password === "string" ? account.password : "";
|
|
2153
|
+
const compareList = [currentHash, ...this.parseHashes(account.previous_password_hashes)].filter(Boolean);
|
|
2154
|
+
for (const h of compareList) {
|
|
2155
|
+
let match = false;
|
|
2156
|
+
try {
|
|
2157
|
+
match = await verify({ password: candidate, hash: h });
|
|
2158
|
+
} catch {
|
|
2159
|
+
match = false;
|
|
2160
|
+
}
|
|
2161
|
+
if (match) {
|
|
2162
|
+
const { APIError } = await import("better-auth/api");
|
|
2163
|
+
throw new APIError("BAD_REQUEST", {
|
|
2164
|
+
message: `For security you can't reuse one of your last ${count} passwords. Please choose a different one.`,
|
|
2165
|
+
code: "PASSWORD_REUSE"
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
return currentHash;
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a
|
|
2173
|
+
* successful change/reset. Best-effort; never throws.
|
|
2174
|
+
*/
|
|
2175
|
+
async recordPasswordHistory(userId, oldHash) {
|
|
2176
|
+
const count = Math.floor(Number(this.config.passwordHistoryCount) || 0);
|
|
2177
|
+
if (count <= 0 || !oldHash) return;
|
|
2178
|
+
const engine = this.getDataEngine();
|
|
2179
|
+
if (!engine) return;
|
|
2180
|
+
try {
|
|
2181
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2182
|
+
const account = await engine.findOne("sys_account", {
|
|
2183
|
+
where: { user_id: userId, provider_id: "credential" },
|
|
2184
|
+
fields: ["id", "previous_password_hashes"],
|
|
2185
|
+
context: SYSTEM_CTX
|
|
2186
|
+
});
|
|
2187
|
+
if (!account?.id) return;
|
|
2188
|
+
const prev = this.parseHashes(account.previous_password_hashes);
|
|
2189
|
+
const next = [oldHash, ...prev.filter((h) => h !== oldHash)].slice(0, count);
|
|
2190
|
+
await engine.update(
|
|
2191
|
+
"sys_account",
|
|
2192
|
+
{ id: account.id, previous_password_hashes: JSON.stringify(next) },
|
|
2193
|
+
{ context: SYSTEM_CTX }
|
|
2194
|
+
);
|
|
2195
|
+
} catch {
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
|
|
2200
|
+
* out (brute-force protection). No-op when lockout is disabled
|
|
2201
|
+
* (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a
|
|
2202
|
+
* lookup error: an infra hiccup must never block every login.
|
|
2203
|
+
*/
|
|
2204
|
+
async assertAccountNotLocked(email) {
|
|
2205
|
+
const threshold = Number(this.config.lockoutThreshold) || 0;
|
|
2206
|
+
if (threshold <= 0) return;
|
|
2207
|
+
const engine = this.getDataEngine();
|
|
2208
|
+
if (!engine) return;
|
|
2209
|
+
let locked = false;
|
|
2210
|
+
try {
|
|
2211
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2212
|
+
const u = await engine.findOne("sys_user", {
|
|
2213
|
+
where: { email },
|
|
2214
|
+
fields: ["id", "locked_until"],
|
|
2215
|
+
context: SYSTEM_CTX
|
|
2216
|
+
});
|
|
2217
|
+
const lu = u?.locked_until;
|
|
2218
|
+
locked = !!(lu && new Date(lu).getTime() > Date.now());
|
|
2219
|
+
} catch {
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
if (locked) {
|
|
2223
|
+
const { APIError } = await import("better-auth/api");
|
|
2224
|
+
throw new APIError("FORBIDDEN", {
|
|
2225
|
+
message: "This account is temporarily locked after too many failed sign-in attempts. Try again later or ask an administrator to unlock it.",
|
|
2226
|
+
code: "ACCOUNT_LOCKED"
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* ADR-0069 D2 — record a sign-in outcome for lockout accounting. On failure
|
|
2232
|
+
* increments `failed_login_count` and, once it reaches `lockoutThreshold`,
|
|
2233
|
+
* stamps `locked_until = now + lockoutDurationMinutes`. On success resets
|
|
2234
|
+
* both (only writing when there is something to clear, to avoid a no-op
|
|
2235
|
+
* history row on every login). No-op when lockout is disabled. Never throws —
|
|
2236
|
+
* a counter write must not turn a valid login into an error.
|
|
2237
|
+
*/
|
|
2238
|
+
async recordSignInOutcome(email, success) {
|
|
2239
|
+
const threshold = Number(this.config.lockoutThreshold) || 0;
|
|
2240
|
+
if (threshold <= 0) return;
|
|
2241
|
+
const engine = this.getDataEngine();
|
|
2242
|
+
if (!engine) return;
|
|
2243
|
+
try {
|
|
2244
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2245
|
+
const u = await engine.findOne("sys_user", {
|
|
2246
|
+
where: { email },
|
|
2247
|
+
fields: ["id", "failed_login_count", "locked_until"],
|
|
2248
|
+
context: SYSTEM_CTX
|
|
2249
|
+
});
|
|
2250
|
+
if (!u?.id) return;
|
|
2251
|
+
if (success) {
|
|
2252
|
+
if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) {
|
|
2253
|
+
await engine.update(
|
|
2254
|
+
"sys_user",
|
|
2255
|
+
{ id: u.id, failed_login_count: 0, locked_until: null },
|
|
2256
|
+
{ context: SYSTEM_CTX }
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const next = (Number(u.failed_login_count) || 0) + 1;
|
|
2262
|
+
const patch = { id: u.id, failed_login_count: next };
|
|
2263
|
+
if (next >= threshold) {
|
|
2264
|
+
const mins = Number(this.config.lockoutDurationMinutes) || 15;
|
|
2265
|
+
patch.locked_until = new Date(Date.now() + mins * 6e4);
|
|
2266
|
+
}
|
|
2267
|
+
await engine.update("sys_user", patch, { context: SYSTEM_CTX });
|
|
2268
|
+
} catch {
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
|
|
2273
|
+
* Resets `failed_login_count` and `locked_until`. Returns false when no data
|
|
2274
|
+
* engine is wired or the user does not exist.
|
|
2275
|
+
*/
|
|
2276
|
+
async unlockUser(userId) {
|
|
2277
|
+
const engine = this.getDataEngine();
|
|
2278
|
+
if (!engine || !userId) return false;
|
|
2279
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2280
|
+
const u = await engine.findOne("sys_user", {
|
|
2281
|
+
where: { id: userId },
|
|
2282
|
+
fields: ["id"],
|
|
2283
|
+
context: SYSTEM_CTX
|
|
2284
|
+
});
|
|
2285
|
+
if (!u?.id) return false;
|
|
2286
|
+
await engine.update(
|
|
2287
|
+
"sys_user",
|
|
2288
|
+
{ id: userId, failed_login_count: 0, locked_until: null },
|
|
2289
|
+
{ context: SYSTEM_CTX }
|
|
2290
|
+
);
|
|
2291
|
+
return true;
|
|
2292
|
+
}
|
|
1457
2293
|
/**
|
|
1458
2294
|
* Returns the data engine wired into this auth manager. Used by route
|
|
1459
2295
|
* handlers (e.g. bootstrap-status) that need to query identity tables
|
|
@@ -1517,7 +2353,9 @@ var authIdentityObjects = [
|
|
|
1517
2353
|
import_identity.SysOauthRefreshToken,
|
|
1518
2354
|
import_identity.SysOauthConsent,
|
|
1519
2355
|
import_identity.SysJwks,
|
|
1520
|
-
import_identity.SysDeviceCode
|
|
2356
|
+
import_identity.SysDeviceCode,
|
|
2357
|
+
import_identity.SysSsoProvider,
|
|
2358
|
+
import_identity.SysScimProvider
|
|
1521
2359
|
];
|
|
1522
2360
|
var authPluginManifestHeader = {
|
|
1523
2361
|
id: AUTH_PLUGIN_ID,
|
|
@@ -1623,14 +2461,24 @@ var AuthPlugin = class {
|
|
|
1623
2461
|
ctx.hook("kernel:ready", async () => {
|
|
1624
2462
|
if (this.authManager) {
|
|
1625
2463
|
await this.bindAuthSettings(ctx);
|
|
2464
|
+
let emailSvc;
|
|
1626
2465
|
try {
|
|
1627
|
-
|
|
1628
|
-
if (emailSvc) {
|
|
1629
|
-
this.authManager.setEmailService(emailSvc);
|
|
1630
|
-
ctx.logger.info("Auth: email service wired (transactional mail enabled)");
|
|
1631
|
-
}
|
|
2466
|
+
emailSvc = ctx.getService("email");
|
|
1632
2467
|
} catch {
|
|
1633
|
-
|
|
2468
|
+
emailSvc = void 0;
|
|
2469
|
+
}
|
|
2470
|
+
if (emailSvc) {
|
|
2471
|
+
this.authManager.setEmailService(emailSvc);
|
|
2472
|
+
ctx.logger.info("Auth: email service wired (transactional mail enabled)");
|
|
2473
|
+
} else {
|
|
2474
|
+
const requiresEmail = !!this.authManager.getPublicConfig?.()?.emailPassword?.requireEmailVerification;
|
|
2475
|
+
if (requiresEmail) {
|
|
2476
|
+
ctx.logger.error(
|
|
2477
|
+
"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)."
|
|
2478
|
+
);
|
|
2479
|
+
} else {
|
|
2480
|
+
ctx.logger.info("Auth: no email service registered \u2014 transactional mail disabled");
|
|
2481
|
+
}
|
|
1634
2482
|
}
|
|
1635
2483
|
try {
|
|
1636
2484
|
const settings = ctx.getService("settings");
|
|
@@ -1693,6 +2541,38 @@ var AuthPlugin = class {
|
|
|
1693
2541
|
ctx.hook("kernel:ready", async () => {
|
|
1694
2542
|
await this.maybeSeedDevAdmin(ctx);
|
|
1695
2543
|
});
|
|
2544
|
+
ctx.hook("kernel:ready", async () => {
|
|
2545
|
+
try {
|
|
2546
|
+
const engine = ctx.getService("objectql");
|
|
2547
|
+
if (!engine || typeof engine.registerHook !== "function") return;
|
|
2548
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2549
|
+
engine.registerHook("afterInsert", async (hookCtx) => {
|
|
2550
|
+
try {
|
|
2551
|
+
if (hookCtx?.object !== "sys_account") return;
|
|
2552
|
+
const acct = hookCtx.result ?? {};
|
|
2553
|
+
const providerId = acct.provider_id ?? acct.providerId;
|
|
2554
|
+
const userId = acct.user_id ?? acct.userId;
|
|
2555
|
+
if (!userId || !providerId || providerId === "credential") return;
|
|
2556
|
+
const credCount = await engine.count("sys_account", {
|
|
2557
|
+
where: { user_id: userId, provider_id: "credential" },
|
|
2558
|
+
context: SYSTEM_CTX
|
|
2559
|
+
});
|
|
2560
|
+
if (typeof credCount === "number" && credCount > 0) return;
|
|
2561
|
+
const u = await engine.findOne("sys_user", {
|
|
2562
|
+
where: { id: userId },
|
|
2563
|
+
fields: ["id", "source"],
|
|
2564
|
+
context: SYSTEM_CTX
|
|
2565
|
+
});
|
|
2566
|
+
if (u && u.source !== "idp_provisioned") {
|
|
2567
|
+
await engine.update("sys_user", { id: userId, source: "idp_provisioned" }, { context: SYSTEM_CTX });
|
|
2568
|
+
}
|
|
2569
|
+
} catch {
|
|
2570
|
+
}
|
|
2571
|
+
}, { packageId: "com.objectstack.plugin-auth" });
|
|
2572
|
+
ctx.logger.info("Identity-source afterInsert stamp registered on sys_account (SCIM-safe)");
|
|
2573
|
+
} catch {
|
|
2574
|
+
}
|
|
2575
|
+
});
|
|
1696
2576
|
try {
|
|
1697
2577
|
const ql = ctx.getService("objectql");
|
|
1698
2578
|
if (ql && typeof ql.registerMiddleware === "function") {
|
|
@@ -1776,6 +2656,23 @@ var AuthPlugin = class {
|
|
|
1776
2656
|
if (Object.keys(emailAndPassword).length > 0) {
|
|
1777
2657
|
patch.emailAndPassword = emailAndPassword;
|
|
1778
2658
|
}
|
|
2659
|
+
if (isExplicit("password_reject_breached")) {
|
|
2660
|
+
patch.plugins = {
|
|
2661
|
+
...patch.plugins ?? {},
|
|
2662
|
+
passwordRejectBreached: asBoolean(values.password_reject_breached, false)
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
if (isExplicit("password_require_complexity")) {
|
|
2666
|
+
patch.passwordRequireComplexity = asBoolean(values.password_require_complexity, false);
|
|
2667
|
+
}
|
|
2668
|
+
if (isExplicit("password_min_classes")) {
|
|
2669
|
+
const n = asPositiveInt(values.password_min_classes);
|
|
2670
|
+
if (n !== void 0) patch.passwordMinClasses = Math.min(4, Math.max(1, n));
|
|
2671
|
+
}
|
|
2672
|
+
if (isExplicit("password_history_count")) {
|
|
2673
|
+
const n = Math.floor(Number(values.password_history_count));
|
|
2674
|
+
if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
|
|
2675
|
+
}
|
|
1779
2676
|
const session = {};
|
|
1780
2677
|
if (isExplicit("session_expiry_days")) {
|
|
1781
2678
|
const d = asPositiveInt(values.session_expiry_days);
|
|
@@ -1788,6 +2685,33 @@ var AuthPlugin = class {
|
|
|
1788
2685
|
if (Object.keys(session).length > 0) {
|
|
1789
2686
|
patch.session = session;
|
|
1790
2687
|
}
|
|
2688
|
+
const asNonNegativeInt = (value) => {
|
|
2689
|
+
const n = Math.floor(Number(value));
|
|
2690
|
+
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
2691
|
+
};
|
|
2692
|
+
if (isExplicit("lockout_threshold")) {
|
|
2693
|
+
const n = asNonNegativeInt(values.lockout_threshold);
|
|
2694
|
+
if (n !== void 0) patch.lockoutThreshold = n;
|
|
2695
|
+
}
|
|
2696
|
+
if (isExplicit("lockout_duration_minutes")) {
|
|
2697
|
+
const n = asPositiveInt(values.lockout_duration_minutes);
|
|
2698
|
+
if (n !== void 0) patch.lockoutDurationMinutes = n;
|
|
2699
|
+
}
|
|
2700
|
+
if (isExplicit("rate_limit_max") || isExplicit("rate_limit_window_seconds")) {
|
|
2701
|
+
const max = asPositiveInt(values.rate_limit_max) ?? 10;
|
|
2702
|
+
const window = asPositiveInt(values.rate_limit_window_seconds) ?? 60;
|
|
2703
|
+
patch.rateLimit = {
|
|
2704
|
+
enabled: true,
|
|
2705
|
+
window,
|
|
2706
|
+
max,
|
|
2707
|
+
customRules: {
|
|
2708
|
+
"/sign-in/email": { window, max },
|
|
2709
|
+
"/sign-up/email": { window, max },
|
|
2710
|
+
"/request-password-reset": { window, max },
|
|
2711
|
+
"/reset-password": { window, max }
|
|
2712
|
+
}
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
1791
2715
|
if (isExplicit("google_enabled") || isExplicit("google_client_id") || isExplicit("google_client_secret")) {
|
|
1792
2716
|
const socialProviders = {
|
|
1793
2717
|
...this.configuredSocialProviders ?? {}
|
|
@@ -1909,9 +2833,12 @@ var AuthPlugin = class {
|
|
|
1909
2833
|
);
|
|
1910
2834
|
}
|
|
1911
2835
|
const rawApp = httpServer.getRawApp();
|
|
1912
|
-
rawApp.get(`${basePath}/config`, (c) => {
|
|
2836
|
+
rawApp.get(`${basePath}/config`, async (c) => {
|
|
1913
2837
|
try {
|
|
1914
2838
|
const config = this.authManager.getPublicConfig();
|
|
2839
|
+
if (config.features?.sso) {
|
|
2840
|
+
config.features.sso = await this.authManager.isSsoUsable();
|
|
2841
|
+
}
|
|
1915
2842
|
return c.json({ success: true, data: config });
|
|
1916
2843
|
} catch (error) {
|
|
1917
2844
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -1997,6 +2924,39 @@ var AuthPlugin = class {
|
|
|
1997
2924
|
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
1998
2925
|
}
|
|
1999
2926
|
});
|
|
2927
|
+
rawApp.post(`${basePath}/admin/unlock-user`, async (c) => {
|
|
2928
|
+
try {
|
|
2929
|
+
let body = {};
|
|
2930
|
+
try {
|
|
2931
|
+
body = await c.req.json();
|
|
2932
|
+
} catch {
|
|
2933
|
+
body = {};
|
|
2934
|
+
}
|
|
2935
|
+
const userId = body?.userId ?? body?.user_id;
|
|
2936
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
2937
|
+
return c.json({ success: false, error: { code: "invalid_request", message: "userId is required" } }, 400);
|
|
2938
|
+
}
|
|
2939
|
+
const authApi = await this.authManager.getApi();
|
|
2940
|
+
const session = await authApi.getSession({ headers: c.req.raw.headers });
|
|
2941
|
+
if (!session?.user?.id) {
|
|
2942
|
+
return c.json({ success: false, error: { code: "unauthorized", message: "Sign in first" } }, 401);
|
|
2943
|
+
}
|
|
2944
|
+
const u = session.user;
|
|
2945
|
+
const isAdmin = u?.isPlatformAdmin === true || Array.isArray(u?.roles) && u.roles.includes("platform_admin") || u?.role === "admin";
|
|
2946
|
+
if (!isAdmin) {
|
|
2947
|
+
return c.json({ success: false, error: { code: "forbidden", message: "Admin role required" } }, 403);
|
|
2948
|
+
}
|
|
2949
|
+
const ok = await this.authManager.unlockUser(userId);
|
|
2950
|
+
if (!ok) {
|
|
2951
|
+
return c.json({ success: false, error: { code: "not_found", message: "User not found or data engine unavailable" } }, 404);
|
|
2952
|
+
}
|
|
2953
|
+
return c.json({ success: true, data: { userId } });
|
|
2954
|
+
} catch (error) {
|
|
2955
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2956
|
+
ctx.logger.error("[AuthPlugin] unlock-user failed", err);
|
|
2957
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
2958
|
+
}
|
|
2959
|
+
});
|
|
2000
2960
|
rawApp.post(`${basePath}/sys-oauth-application/register`, async (c) => {
|
|
2001
2961
|
try {
|
|
2002
2962
|
let body = {};
|
|
@@ -2143,7 +3103,9 @@ var AuthPlugin = class {
|
|
|
2143
3103
|
AUTH_OAUTH_REFRESH_TOKEN_SCHEMA,
|
|
2144
3104
|
AUTH_ORGANIZATION_SCHEMA,
|
|
2145
3105
|
AUTH_ORG_SESSION_FIELDS,
|
|
3106
|
+
AUTH_SCIM_PROVIDER_SCHEMA,
|
|
2146
3107
|
AUTH_SESSION_CONFIG,
|
|
3108
|
+
AUTH_SSO_PROVIDER_SCHEMA,
|
|
2147
3109
|
AUTH_TEAM_MEMBER_SCHEMA,
|
|
2148
3110
|
AUTH_TEAM_SCHEMA,
|
|
2149
3111
|
AUTH_TWO_FACTOR_SCHEMA,
|
|
@@ -2162,6 +3124,7 @@ var AuthPlugin = class {
|
|
|
2162
3124
|
createObjectQLAdapter,
|
|
2163
3125
|
createObjectQLAdapterFactory,
|
|
2164
3126
|
resolveProtocolName,
|
|
2165
|
-
runSetInitialPassword
|
|
3127
|
+
runSetInitialPassword,
|
|
3128
|
+
withSystemReadContext
|
|
2166
3129
|
});
|
|
2167
3130
|
//# sourceMappingURL=index.js.map
|