@objectstack/plugin-auth 11.0.0 → 11.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +194 -14
- package/dist/index.d.ts +194 -14
- package/dist/index.js +725 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +720 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -65,8 +65,13 @@ __export(index_exports, {
|
|
|
65
65
|
buildTwoFactorPluginSchema: () => buildTwoFactorPluginSchema,
|
|
66
66
|
createObjectQLAdapter: () => createObjectQLAdapter,
|
|
67
67
|
createObjectQLAdapterFactory: () => createObjectQLAdapterFactory,
|
|
68
|
+
ipMatchesRange: () => ipMatchesRange,
|
|
68
69
|
resolveProtocolName: () => resolveProtocolName,
|
|
70
|
+
runRegisterSamlProviderFromForm: () => runRegisterSamlProviderFromForm,
|
|
71
|
+
runRegisterSsoProviderFromForm: () => runRegisterSsoProviderFromForm,
|
|
72
|
+
runRequestDomainVerification: () => runRequestDomainVerification,
|
|
69
73
|
runSetInitialPassword: () => runSetInitialPassword,
|
|
74
|
+
runVerifyDomain: () => runVerifyDomain,
|
|
70
75
|
withSystemReadContext: () => withSystemReadContext
|
|
71
76
|
});
|
|
72
77
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -592,7 +597,13 @@ var AUTH_SSO_PROVIDER_SCHEMA = {
|
|
|
592
597
|
oidcConfig: "oidc_config",
|
|
593
598
|
samlConfig: "saml_config",
|
|
594
599
|
userId: "user_id",
|
|
595
|
-
organizationId: "organization_id"
|
|
600
|
+
organizationId: "organization_id",
|
|
601
|
+
// DNS domain-ownership proof (ADR-0024 ②). @better-auth/sso writes
|
|
602
|
+
// `domainVerified` on its `ssoProvider` model when domain verification is
|
|
603
|
+
// enabled; map it so the env can surface a verified/unverified badge. The
|
|
604
|
+
// one-time `domainVerificationToken` is NOT a provider column — it lives in
|
|
605
|
+
// the verification table and is returned only from request-domain-verification.
|
|
606
|
+
domainVerified: "domain_verified"
|
|
596
607
|
}
|
|
597
608
|
};
|
|
598
609
|
var AUTH_SCIM_PROVIDER_SCHEMA = {
|
|
@@ -671,9 +682,36 @@ function readDisableSignUpEnv() {
|
|
|
671
682
|
function readSsoOnlyEnv() {
|
|
672
683
|
return readBooleanEnv("OS_AUTH_SSO_ONLY");
|
|
673
684
|
}
|
|
685
|
+
function ipv4ToInt(ip) {
|
|
686
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim());
|
|
687
|
+
if (!m) return null;
|
|
688
|
+
const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
689
|
+
if (p.some((n) => n > 255)) return null;
|
|
690
|
+
return (p[0] << 24 >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3] >>> 0;
|
|
691
|
+
}
|
|
692
|
+
function ipMatchesRange(ip, range) {
|
|
693
|
+
const r = (range || "").trim();
|
|
694
|
+
if (!r) return false;
|
|
695
|
+
if (r.includes("/")) {
|
|
696
|
+
const [base, bitsStr] = r.split("/");
|
|
697
|
+
const bits = Number(bitsStr);
|
|
698
|
+
const ipInt = ipv4ToInt(ip);
|
|
699
|
+
const baseInt = ipv4ToInt(base);
|
|
700
|
+
if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) {
|
|
701
|
+
return ip.trim() === base.trim();
|
|
702
|
+
}
|
|
703
|
+
const mask = bits === 0 ? 0 : ~0 << 32 - bits >>> 0;
|
|
704
|
+
return (ipInt & mask) >>> 0 === (baseInt & mask) >>> 0;
|
|
705
|
+
}
|
|
706
|
+
return ip.trim() === r;
|
|
707
|
+
}
|
|
674
708
|
var AuthManager = class {
|
|
675
709
|
constructor(config) {
|
|
676
710
|
this.auth = null;
|
|
711
|
+
// ADR-0069 — cached "does any org require MFA" flag (per-org tightening).
|
|
712
|
+
// Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap.
|
|
713
|
+
this._orgMfaCache = { value: false, at: 0 };
|
|
714
|
+
this._orgMfaRefreshing = false;
|
|
677
715
|
this.config = config;
|
|
678
716
|
installWebContainerRequestStatePolyfill();
|
|
679
717
|
if (config.authInstance) {
|
|
@@ -878,6 +916,7 @@ var AuthManager = class {
|
|
|
878
916
|
if (candidate && (ctx?.path === "/reset-password" || ctx?.path === "/change-password")) {
|
|
879
917
|
const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => void 0);
|
|
880
918
|
if (userId) {
|
|
919
|
+
ctx.context.__osPwChangeUserId = userId;
|
|
881
920
|
const pw = ctx?.context?.password;
|
|
882
921
|
const verify = typeof pw?.verify === "function" ? pw.verify.bind(pw) : void 0;
|
|
883
922
|
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
|
|
@@ -1017,25 +1056,43 @@ var AuthManager = class {
|
|
|
1017
1056
|
succeeded = !(ctx?.context?.returned instanceof Error);
|
|
1018
1057
|
}
|
|
1019
1058
|
await this.recordSignInOutcome(email, succeeded);
|
|
1059
|
+
if (succeeded) {
|
|
1060
|
+
const uid = ctx?.context?.returned?.user?.id;
|
|
1061
|
+
if (typeof uid === "string") await this.enforceConcurrentCap(uid);
|
|
1062
|
+
}
|
|
1020
1063
|
}
|
|
1021
1064
|
return;
|
|
1022
1065
|
}
|
|
1023
1066
|
if (ctx?.path === "/change-password" || ctx?.path === "/reset-password") {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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;
|
|
1067
|
+
let succeeded;
|
|
1068
|
+
try {
|
|
1069
|
+
const { isAPIError } = await import("better-auth/api");
|
|
1070
|
+
succeeded = !isAPIError(ctx?.context?.returned);
|
|
1071
|
+
} catch {
|
|
1072
|
+
succeeded = !(ctx?.context?.returned instanceof Error);
|
|
1035
1073
|
}
|
|
1074
|
+
if (succeeded) {
|
|
1075
|
+
const stampId = ctx?.context?.__osPwChangeUserId;
|
|
1076
|
+
if (stampId) await this.stampPasswordChangedAt(stampId);
|
|
1077
|
+
const stash = ctx?.context?.__osPwHistory;
|
|
1078
|
+
if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
|
|
1079
|
+
}
|
|
1080
|
+
delete ctx.context.__osPwChangeUserId;
|
|
1081
|
+
delete ctx.context.__osPwHistory;
|
|
1036
1082
|
return;
|
|
1037
1083
|
}
|
|
1038
1084
|
if (ctx?.path !== "/sign-up/email") return;
|
|
1085
|
+
{
|
|
1086
|
+
const newUserId = ctx?.context?.returned?.user?.id;
|
|
1087
|
+
let signupOk;
|
|
1088
|
+
try {
|
|
1089
|
+
const { isAPIError } = await import("better-auth/api");
|
|
1090
|
+
signupOk = !isAPIError(ctx?.context?.returned);
|
|
1091
|
+
} catch {
|
|
1092
|
+
signupOk = !(ctx?.context?.returned instanceof Error);
|
|
1093
|
+
}
|
|
1094
|
+
if (signupOk && typeof newUserId === "string") await this.stampPasswordChangedAt(newUserId);
|
|
1095
|
+
}
|
|
1039
1096
|
const ep = ctx?.context?.options?.emailAndPassword;
|
|
1040
1097
|
if (ep && ctx.context.__osDisableSignUpOrig !== void 0) {
|
|
1041
1098
|
ep.disableSignUp = ctx.context.__osDisableSignUpOrig;
|
|
@@ -1047,7 +1104,7 @@ var AuthManager = class {
|
|
|
1047
1104
|
// Auto-includes origins from OS_CORS_ORIGIN env var so CORS and CSRF stay in sync.
|
|
1048
1105
|
...(() => {
|
|
1049
1106
|
const origins = [...this.config.trustedOrigins || []];
|
|
1050
|
-
const corsOrigin = (0, import_types.readEnvWithDeprecation)("OS_CORS_ORIGIN", "CORS_ORIGIN");
|
|
1107
|
+
const corsOrigin = (0, import_types.readEnvWithDeprecation)("OS_CORS_ORIGIN", "CORS_ORIGIN", { silent: true });
|
|
1051
1108
|
if (corsOrigin && corsOrigin !== "*") {
|
|
1052
1109
|
corsOrigin.split(",").map((s) => s.trim()).filter(Boolean).forEach((o) => {
|
|
1053
1110
|
if (!origins.includes(o)) origins.push(o);
|
|
@@ -1147,12 +1204,10 @@ var AuthManager = class {
|
|
|
1147
1204
|
async buildPluginList() {
|
|
1148
1205
|
const pluginConfig = this.config.plugins ?? {};
|
|
1149
1206
|
const plugins = [];
|
|
1150
|
-
const
|
|
1151
|
-
const
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
const scimEnv = globalThis?.process?.env?.OS_SCIM_ENABLED;
|
|
1155
|
-
const scimFromEnv = scimEnv != null ? String(scimEnv).toLowerCase() === "true" : void 0;
|
|
1207
|
+
const oidcFromEnv = readBooleanEnv("OS_OIDC_PROVIDER_ENABLED");
|
|
1208
|
+
const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
|
|
1209
|
+
const scimFromEnv = readBooleanEnv("OS_SCIM_ENABLED");
|
|
1210
|
+
const ssoDomainVerifyFromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
|
|
1156
1211
|
const scimEffective = scimFromEnv ?? pluginConfig.scim ?? false;
|
|
1157
1212
|
const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
|
|
1158
1213
|
const hibpFromEnv = readBooleanEnv("OS_AUTH_PASSWORD_REJECT_BREACHED");
|
|
@@ -1166,6 +1221,7 @@ var AuthManager = class {
|
|
|
1166
1221
|
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
|
|
1167
1222
|
admin: pluginConfig.admin ?? scimEffective,
|
|
1168
1223
|
sso: ssoFromEnv ?? pluginConfig.sso ?? false,
|
|
1224
|
+
ssoDomainVerification: ssoDomainVerifyFromEnv ?? pluginConfig.ssoDomainVerification ?? false,
|
|
1169
1225
|
scim: scimEffective
|
|
1170
1226
|
};
|
|
1171
1227
|
const { bearer } = await import("better-auth/plugins/bearer");
|
|
@@ -1249,9 +1305,8 @@ var AuthManager = class {
|
|
|
1249
1305
|
// The plugin itself is always installed (so list/update/invite endpoints
|
|
1250
1306
|
// keep responding); only the `create` operation is denied when the
|
|
1251
1307
|
// deployment is provisioned in single-org mode. Resolution order:
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
// multi-org), default `'false'` → single-org / per-env runtime.
|
|
1308
|
+
// `OS_MULTI_ORG_ENABLED` (default `'false'` → single-org /
|
|
1309
|
+
// per-env runtime).
|
|
1255
1310
|
beforeCreateOrganization: async () => {
|
|
1256
1311
|
if (!(0, import_types.resolveMultiOrgEnabled)()) {
|
|
1257
1312
|
const { APIError } = await import("better-auth/api");
|
|
@@ -1432,7 +1487,8 @@ var AuthManager = class {
|
|
|
1432
1487
|
if (enabled.sso) {
|
|
1433
1488
|
const { sso } = await import("@better-auth/sso");
|
|
1434
1489
|
plugins.push(sso({
|
|
1435
|
-
organizationProvisioning: { defaultRole: "member" }
|
|
1490
|
+
organizationProvisioning: { defaultRole: "member" },
|
|
1491
|
+
...enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}
|
|
1436
1492
|
}));
|
|
1437
1493
|
}
|
|
1438
1494
|
if (enabled.scim) {
|
|
@@ -1504,7 +1560,16 @@ var AuthManager = class {
|
|
|
1504
1560
|
...orgRoles,
|
|
1505
1561
|
...platformAdmin ? [import_spec.BUILTIN_ROLE_PLATFORM_ADMIN] : []
|
|
1506
1562
|
]));
|
|
1507
|
-
|
|
1563
|
+
await this.enforceSessionControls(session?.id, session?.createdAt);
|
|
1564
|
+
const authGate = await this.computeAuthGate(
|
|
1565
|
+
user.id,
|
|
1566
|
+
session?.activeOrganizationId,
|
|
1567
|
+
user?.twoFactorEnabled === true
|
|
1568
|
+
);
|
|
1569
|
+
return {
|
|
1570
|
+
user: { ...user, roles, isPlatformAdmin: platformAdmin, ...authGate ? { authGate } : {} },
|
|
1571
|
+
session
|
|
1572
|
+
};
|
|
1508
1573
|
}));
|
|
1509
1574
|
}
|
|
1510
1575
|
return plugins;
|
|
@@ -1534,7 +1599,7 @@ var AuthManager = class {
|
|
|
1534
1599
|
* Generate a secure secret if not provided
|
|
1535
1600
|
*/
|
|
1536
1601
|
generateSecret() {
|
|
1537
|
-
const envSecret = (0, import_types.readEnvWithDeprecation)("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"]);
|
|
1602
|
+
const envSecret = (0, import_types.readEnvWithDeprecation)("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"], { silent: true });
|
|
1538
1603
|
if (envSecret) return envSecret;
|
|
1539
1604
|
if (process.env.NODE_ENV === "production") {
|
|
1540
1605
|
throw new Error(
|
|
@@ -1809,12 +1874,28 @@ var AuthManager = class {
|
|
|
1809
1874
|
* in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
|
|
1810
1875
|
* advertised capability can never disagree with the actual `/sign-in/sso`
|
|
1811
1876
|
* route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
|
|
1877
|
+
* Public so `AuthPlugin` can gate the Setup-nav "SSO Providers" entry on it
|
|
1878
|
+
* (captures both self-host `OS_SSO_ENABLED` and the cloud per-env
|
|
1879
|
+
* `planAllowsSso` config, since that arrives via `plugins.sso`).
|
|
1812
1880
|
*/
|
|
1813
1881
|
isSsoWired() {
|
|
1814
|
-
const
|
|
1815
|
-
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === "true" : void 0;
|
|
1882
|
+
const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
|
|
1816
1883
|
return ssoFromEnv ?? this.config.plugins?.sso ?? false;
|
|
1817
1884
|
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Whether opt-in DNS domain-verification (ADR-0024 ②) is wired — i.e. the
|
|
1887
|
+
* `/sso/request-domain-verification` + `/sso/verify-domain` endpoints are
|
|
1888
|
+
* mounted (and the hard "domain must be verified to log in" gate is active).
|
|
1889
|
+
* Resolved with the EXACT logic `buildPluginList` uses for the `sso()`
|
|
1890
|
+
* `domainVerification.enabled` option, so the bridge can return a clear
|
|
1891
|
+
* "not enabled for this environment" instead of a bare 404 when off.
|
|
1892
|
+
* Implies `isSsoWired()` (the sso plugin must be loaded to honor it).
|
|
1893
|
+
*/
|
|
1894
|
+
isSsoDomainVerificationEnabled() {
|
|
1895
|
+
if (!this.isSsoWired()) return false;
|
|
1896
|
+
const fromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
|
|
1897
|
+
return fromEnv ?? this.config.plugins?.ssoDomainVerification ?? false;
|
|
1898
|
+
}
|
|
1818
1899
|
/**
|
|
1819
1900
|
* Whether enterprise SSO is actually *usable*, not merely wired: the plugin
|
|
1820
1901
|
* is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
|
|
@@ -2074,6 +2155,119 @@ var AuthManager = class {
|
|
|
2074
2155
|
});
|
|
2075
2156
|
}
|
|
2076
2157
|
}
|
|
2158
|
+
/**
|
|
2159
|
+
* ADR-0069 — is any authentication-policy gate enabled? Cheap, synchronous;
|
|
2160
|
+
* lets the transport seams skip session lookups entirely when off (the
|
|
2161
|
+
* default), keeping the gate zero-overhead until an admin opts in.
|
|
2162
|
+
*/
|
|
2163
|
+
isAuthGateActive() {
|
|
2164
|
+
this.refreshOrgMfaCacheIfStale();
|
|
2165
|
+
return Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || this.config.mfaRequired === true || this._orgMfaCache.value;
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
2168
|
+
* ADR-0069 — refresh the "any org requires MFA" cache in the background when
|
|
2169
|
+
* stale (60s TTL). Fire-and-forget: a brand-new per-org requirement activates
|
|
2170
|
+
* the gate on the next request, never blocking this one. No-op when global MFA
|
|
2171
|
+
* is already on (the gate is active regardless).
|
|
2172
|
+
*/
|
|
2173
|
+
refreshOrgMfaCacheIfStale() {
|
|
2174
|
+
if (this.config.mfaRequired === true) return;
|
|
2175
|
+
if (this._orgMfaRefreshing) return;
|
|
2176
|
+
if (Date.now() - this._orgMfaCache.at < 6e4) return;
|
|
2177
|
+
const engine = this.getDataEngine();
|
|
2178
|
+
if (!engine) return;
|
|
2179
|
+
this._orgMfaRefreshing = true;
|
|
2180
|
+
void (async () => {
|
|
2181
|
+
try {
|
|
2182
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2183
|
+
const n = await engine.count("sys_organization", {
|
|
2184
|
+
where: { require_mfa: true },
|
|
2185
|
+
context: SYSTEM_CTX
|
|
2186
|
+
});
|
|
2187
|
+
this._orgMfaCache = { value: typeof n === "number" && n > 0, at: Date.now() };
|
|
2188
|
+
} catch {
|
|
2189
|
+
} finally {
|
|
2190
|
+
this._orgMfaRefreshing = false;
|
|
2191
|
+
}
|
|
2192
|
+
})();
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* ADR-0069 — compute the auth-policy gate posture for a session. Returns an
|
|
2196
|
+
* `{ code, message }` when the user is currently blocked (e.g. password
|
|
2197
|
+
* expired), else undefined. No-op (and no DB read) when no gate feature is
|
|
2198
|
+
* enabled. Fails OPEN on any lookup error — a transient hiccup must never lock
|
|
2199
|
+
* a compliant user out.
|
|
2200
|
+
*/
|
|
2201
|
+
async computeAuthGate(userId, _activeOrgId, _twoFactorEnabledHint) {
|
|
2202
|
+
const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0);
|
|
2203
|
+
const mfaGlobal = this.config.mfaRequired === true;
|
|
2204
|
+
const orgMaybeRequires = !mfaGlobal && !!_activeOrgId && this._orgMfaCache.value;
|
|
2205
|
+
if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires) return void 0;
|
|
2206
|
+
const engine = this.getDataEngine();
|
|
2207
|
+
if (!engine || !userId) return void 0;
|
|
2208
|
+
try {
|
|
2209
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2210
|
+
const u = await engine.findOne("sys_user", {
|
|
2211
|
+
where: { id: userId },
|
|
2212
|
+
fields: ["password_changed_at", "two_factor_enabled", "mfa_required_at"],
|
|
2213
|
+
context: SYSTEM_CTX
|
|
2214
|
+
});
|
|
2215
|
+
let mfaRequired = mfaGlobal;
|
|
2216
|
+
if (!mfaRequired && orgMaybeRequires) {
|
|
2217
|
+
const org = await engine.findOne("sys_organization", {
|
|
2218
|
+
where: { id: _activeOrgId },
|
|
2219
|
+
fields: ["require_mfa"],
|
|
2220
|
+
context: SYSTEM_CTX
|
|
2221
|
+
});
|
|
2222
|
+
mfaRequired = org?.require_mfa === true || org?.require_mfa === 1;
|
|
2223
|
+
}
|
|
2224
|
+
if (expiryDays > 0) {
|
|
2225
|
+
const changed = u?.password_changed_at;
|
|
2226
|
+
if (changed && Date.now() - new Date(changed).getTime() > expiryDays * 864e5) {
|
|
2227
|
+
return {
|
|
2228
|
+
code: "PASSWORD_EXPIRED",
|
|
2229
|
+
message: "Your password has expired. Please change it to continue."
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
if (mfaRequired && !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1)) {
|
|
2234
|
+
const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7)));
|
|
2235
|
+
let requiredAt = u?.mfa_required_at;
|
|
2236
|
+
if (!requiredAt) {
|
|
2237
|
+
requiredAt = /* @__PURE__ */ new Date();
|
|
2238
|
+
engine.update("sys_user", { id: userId, mfa_required_at: requiredAt }, { context: SYSTEM_CTX }).catch(() => void 0);
|
|
2239
|
+
}
|
|
2240
|
+
const elapsedMs = Date.now() - new Date(requiredAt).getTime();
|
|
2241
|
+
if (elapsedMs > graceDays * 864e5) {
|
|
2242
|
+
return {
|
|
2243
|
+
code: "MFA_REQUIRED",
|
|
2244
|
+
message: "Multi-factor authentication is required. Please set up an authenticator app to continue."
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
} catch {
|
|
2249
|
+
return void 0;
|
|
2250
|
+
}
|
|
2251
|
+
return void 0;
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* ADR-0069 D1 — stamp `sys_user.password_changed_at = now` after a password is
|
|
2255
|
+
* set (sign-up / change / reset). Best-effort; never throws. Written as a Date
|
|
2256
|
+
* (never epoch-ms) per ADR-0074.
|
|
2257
|
+
*/
|
|
2258
|
+
async stampPasswordChangedAt(userId) {
|
|
2259
|
+
const engine = this.getDataEngine();
|
|
2260
|
+
if (!engine || !userId) return;
|
|
2261
|
+
try {
|
|
2262
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2263
|
+
await engine.update(
|
|
2264
|
+
"sys_user",
|
|
2265
|
+
{ id: userId, password_changed_at: /* @__PURE__ */ new Date() },
|
|
2266
|
+
{ context: SYSTEM_CTX }
|
|
2267
|
+
);
|
|
2268
|
+
} catch {
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2077
2271
|
/**
|
|
2078
2272
|
* ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
|
|
2079
2273
|
* a string[] of hashes, tolerating null / malformed values.
|
|
@@ -2290,6 +2484,96 @@ var AuthManager = class {
|
|
|
2290
2484
|
);
|
|
2291
2485
|
return true;
|
|
2292
2486
|
}
|
|
2487
|
+
/**
|
|
2488
|
+
* ADR-0069 D4 — idle / absolute session enforcement, run per request from
|
|
2489
|
+
* `customSession`. No-op when both are off. Revokes (expires in place +
|
|
2490
|
+
* stamps revoked_at/revoke_reason) when a limit is exceeded so better-auth
|
|
2491
|
+
* returns no session on the NEXT request; otherwise touches `last_activity_at`
|
|
2492
|
+
* (throttled to once a minute). Best-effort — never throws.
|
|
2493
|
+
*/
|
|
2494
|
+
async enforceSessionControls(sessionId, createdAtHint) {
|
|
2495
|
+
const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0);
|
|
2496
|
+
const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0);
|
|
2497
|
+
if (idleMin <= 0 && absHrs <= 0) return;
|
|
2498
|
+
const engine = this.getDataEngine();
|
|
2499
|
+
if (!engine || !sessionId) return;
|
|
2500
|
+
try {
|
|
2501
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2502
|
+
const srow = await engine.findOne("sys_session", {
|
|
2503
|
+
where: { id: sessionId },
|
|
2504
|
+
fields: ["id", "created_at", "last_activity_at", "revoked_at"],
|
|
2505
|
+
context: SYSTEM_CTX
|
|
2506
|
+
});
|
|
2507
|
+
if (!srow?.id || srow.revoked_at) return;
|
|
2508
|
+
const now = Date.now();
|
|
2509
|
+
let reason;
|
|
2510
|
+
if (absHrs > 0) {
|
|
2511
|
+
const created = srow.created_at ?? createdAtHint;
|
|
2512
|
+
if (created && now - new Date(created).getTime() > absHrs * 36e5) reason = "absolute_max";
|
|
2513
|
+
}
|
|
2514
|
+
if (!reason && idleMin > 0) {
|
|
2515
|
+
const last = srow.last_activity_at ?? srow.created_at ?? createdAtHint;
|
|
2516
|
+
if (last && now - new Date(last).getTime() > idleMin * 6e4) reason = "idle_timeout";
|
|
2517
|
+
}
|
|
2518
|
+
if (reason) {
|
|
2519
|
+
await engine.update(
|
|
2520
|
+
"sys_session",
|
|
2521
|
+
{ id: sessionId, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: reason },
|
|
2522
|
+
{ context: SYSTEM_CTX }
|
|
2523
|
+
).catch(() => void 0);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
if (idleMin > 0) {
|
|
2527
|
+
const la = srow.last_activity_at ? new Date(srow.last_activity_at).getTime() : 0;
|
|
2528
|
+
if (now - la > 6e4) {
|
|
2529
|
+
await engine.update("sys_session", { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX }).catch(() => void 0);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
} catch {
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* ADR-0069 D4 — concurrent-session cap, run from the sign-in after-hook.
|
|
2537
|
+
* Keeps the newest `maxConcurrentSessions` live sessions for the user and
|
|
2538
|
+
* revokes the rest (oldest first). No-op when off. Best-effort.
|
|
2539
|
+
*/
|
|
2540
|
+
async enforceConcurrentCap(userId) {
|
|
2541
|
+
const cap = Math.floor(Number(this.config.maxConcurrentSessions) || 0);
|
|
2542
|
+
if (cap <= 0 || !userId) return;
|
|
2543
|
+
const engine = this.getDataEngine();
|
|
2544
|
+
if (!engine) return;
|
|
2545
|
+
try {
|
|
2546
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2547
|
+
const rows = await engine.find("sys_session", {
|
|
2548
|
+
where: { user_id: userId },
|
|
2549
|
+
fields: ["id", "created_at", "expires_at", "revoked_at"],
|
|
2550
|
+
limit: 200,
|
|
2551
|
+
context: SYSTEM_CTX
|
|
2552
|
+
});
|
|
2553
|
+
const now = Date.now();
|
|
2554
|
+
const live = (Array.isArray(rows) ? rows : []).filter((sn) => !sn.revoked_at && (!sn.expires_at || new Date(sn.expires_at).getTime() > now)).sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
|
2555
|
+
for (const sn of live.slice(cap)) {
|
|
2556
|
+
await engine.update(
|
|
2557
|
+
"sys_session",
|
|
2558
|
+
{ id: sn.id, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: "concurrent_cap" },
|
|
2559
|
+
{ context: SYSTEM_CTX }
|
|
2560
|
+
).catch(() => void 0);
|
|
2561
|
+
}
|
|
2562
|
+
} catch {
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no
|
|
2567
|
+
* ranges are configured, OR when the IP can't be determined (fail-open so a
|
|
2568
|
+
* misconfigured proxy never locks everyone out — an admin enabling this must
|
|
2569
|
+
* ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6.
|
|
2570
|
+
*/
|
|
2571
|
+
isClientIpAllowed(ip) {
|
|
2572
|
+
const ranges = this.config.allowedIpRanges;
|
|
2573
|
+
if (!ranges || ranges.length === 0) return true;
|
|
2574
|
+
if (!ip) return true;
|
|
2575
|
+
return ranges.some((r) => ipMatchesRange(ip, r));
|
|
2576
|
+
}
|
|
2293
2577
|
/**
|
|
2294
2578
|
* Returns the data engine wired into this auth manager. Used by route
|
|
2295
2579
|
* handlers (e.g. bootstrap-status) that need to query identity tables
|
|
@@ -2331,6 +2615,283 @@ function mapSetPasswordError(error) {
|
|
|
2331
2615
|
return { status, body: { success: false, error: { code, message } } };
|
|
2332
2616
|
}
|
|
2333
2617
|
|
|
2618
|
+
// src/register-sso-provider.ts
|
|
2619
|
+
async function resolveActiveOrganizationId(handle, registerUrl, headers) {
|
|
2620
|
+
try {
|
|
2621
|
+
const sessionUrl = registerUrl.replace(/\/sso\/register$/, "/get-session");
|
|
2622
|
+
if (sessionUrl === registerUrl) return void 0;
|
|
2623
|
+
const h = new Headers({ accept: "application/json" });
|
|
2624
|
+
const cookie = headers.get("cookie");
|
|
2625
|
+
if (cookie) h.set("cookie", cookie);
|
|
2626
|
+
const authz = headers.get("authorization");
|
|
2627
|
+
if (authz) h.set("authorization", authz);
|
|
2628
|
+
const resp = await handle(new Request(sessionUrl, { method: "GET", headers: h }));
|
|
2629
|
+
if (!resp.ok) return void 0;
|
|
2630
|
+
const data = await resp.json().catch(() => null);
|
|
2631
|
+
const org = data?.session?.activeOrganizationId ?? data?.activeOrganizationId;
|
|
2632
|
+
return typeof org === "string" && org.length > 0 ? org : void 0;
|
|
2633
|
+
} catch {
|
|
2634
|
+
return void 0;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
async function runRegisterSsoProviderFromForm(handle, request) {
|
|
2638
|
+
let body;
|
|
2639
|
+
try {
|
|
2640
|
+
body = await request.json();
|
|
2641
|
+
} catch {
|
|
2642
|
+
body = {};
|
|
2643
|
+
}
|
|
2644
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2645
|
+
const providerId = str(body?.providerId);
|
|
2646
|
+
const issuer = str(body?.issuer);
|
|
2647
|
+
const domain = str(body?.domain);
|
|
2648
|
+
const clientId = str(body?.clientId);
|
|
2649
|
+
const clientSecret = str(body?.clientSecret);
|
|
2650
|
+
const discoveryEndpoint = str(body?.discoveryEndpoint);
|
|
2651
|
+
const scopesRaw = str(body?.scopes);
|
|
2652
|
+
const missing = [
|
|
2653
|
+
["providerId", providerId],
|
|
2654
|
+
["issuer", issuer],
|
|
2655
|
+
["domain", domain],
|
|
2656
|
+
["clientId", clientId],
|
|
2657
|
+
["clientSecret", clientSecret]
|
|
2658
|
+
].filter(([, v]) => !v).map(([k]) => k);
|
|
2659
|
+
if (missing.length) {
|
|
2660
|
+
return {
|
|
2661
|
+
status: 400,
|
|
2662
|
+
body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } }
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
const oidcConfig = { clientId, clientSecret };
|
|
2666
|
+
if (discoveryEndpoint) oidcConfig.discoveryEndpoint = discoveryEndpoint;
|
|
2667
|
+
oidcConfig.scopes = scopesRaw ? scopesRaw.split(/[\s,]+/).filter(Boolean) : ["openid", "email", "profile"];
|
|
2668
|
+
oidcConfig.mapping = {
|
|
2669
|
+
id: str(body?.mapId) || "sub",
|
|
2670
|
+
email: str(body?.mapEmail) || "email",
|
|
2671
|
+
name: str(body?.mapName) || "name"
|
|
2672
|
+
};
|
|
2673
|
+
let innerUrl;
|
|
2674
|
+
let origin;
|
|
2675
|
+
try {
|
|
2676
|
+
const url = new URL(request.url);
|
|
2677
|
+
origin = url.origin;
|
|
2678
|
+
innerUrl = `${origin}${url.pathname.replace(/\/admin\/sso\/register$/, "/sso/register")}`;
|
|
2679
|
+
} catch {
|
|
2680
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2681
|
+
}
|
|
2682
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2683
|
+
const cookie = request.headers.get("cookie");
|
|
2684
|
+
if (cookie) headers.set("cookie", cookie);
|
|
2685
|
+
const authz = request.headers.get("authorization");
|
|
2686
|
+
if (authz) headers.set("authorization", authz);
|
|
2687
|
+
headers.set("origin", request.headers.get("origin") || origin);
|
|
2688
|
+
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
|
|
2689
|
+
const innerReq = new Request(innerUrl, {
|
|
2690
|
+
method: "POST",
|
|
2691
|
+
headers,
|
|
2692
|
+
body: JSON.stringify({ providerId, issuer, domain, oidcConfig, ...organizationId ? { organizationId } : {} })
|
|
2693
|
+
});
|
|
2694
|
+
const resp = await handle(innerReq);
|
|
2695
|
+
let parsed = {};
|
|
2696
|
+
try {
|
|
2697
|
+
const t = await resp.text();
|
|
2698
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2699
|
+
} catch {
|
|
2700
|
+
parsed = {};
|
|
2701
|
+
}
|
|
2702
|
+
if (!resp.ok) {
|
|
2703
|
+
return {
|
|
2704
|
+
status: resp.status,
|
|
2705
|
+
body: { success: false, error: { code: "sso_register_failed", message: parsed?.message || "SSO provider registration failed" } }
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } };
|
|
2709
|
+
}
|
|
2710
|
+
async function runRegisterSamlProviderFromForm(handle, request) {
|
|
2711
|
+
let body;
|
|
2712
|
+
try {
|
|
2713
|
+
body = await request.json();
|
|
2714
|
+
} catch {
|
|
2715
|
+
body = {};
|
|
2716
|
+
}
|
|
2717
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2718
|
+
const providerId = str(body?.providerId);
|
|
2719
|
+
const issuer = str(body?.issuer);
|
|
2720
|
+
const domain = str(body?.domain);
|
|
2721
|
+
const entryPoint = str(body?.entryPoint);
|
|
2722
|
+
const cert = str(body?.cert);
|
|
2723
|
+
const identifierFormat = str(body?.identifierFormat);
|
|
2724
|
+
const missing = [
|
|
2725
|
+
["providerId", providerId],
|
|
2726
|
+
["issuer", issuer],
|
|
2727
|
+
["domain", domain],
|
|
2728
|
+
["entryPoint", entryPoint],
|
|
2729
|
+
["cert", cert]
|
|
2730
|
+
].filter(([, v]) => !v).map(([k]) => k);
|
|
2731
|
+
if (missing.length) {
|
|
2732
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } } };
|
|
2733
|
+
}
|
|
2734
|
+
let origin;
|
|
2735
|
+
let prefix;
|
|
2736
|
+
let innerUrl;
|
|
2737
|
+
try {
|
|
2738
|
+
const url = new URL(request.url);
|
|
2739
|
+
origin = url.origin;
|
|
2740
|
+
prefix = url.pathname.replace(/\/admin\/sso\/register-saml$/, "");
|
|
2741
|
+
innerUrl = `${origin}${prefix}/sso/register`;
|
|
2742
|
+
} catch {
|
|
2743
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2744
|
+
}
|
|
2745
|
+
const acsUrl = `${origin}${prefix}/sso/saml2/sp/acs/${encodeURIComponent(providerId)}`;
|
|
2746
|
+
const spMetadataUrl = `${origin}${prefix}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}`;
|
|
2747
|
+
const samlConfig = {
|
|
2748
|
+
entryPoint,
|
|
2749
|
+
cert,
|
|
2750
|
+
callbackUrl: acsUrl,
|
|
2751
|
+
// better-auth requires an SP descriptor (its inner fields are optional). Use
|
|
2752
|
+
// the SP metadata URL as our EntityID — the value the IdP keys this SP on.
|
|
2753
|
+
spMetadata: { entityID: spMetadataUrl }
|
|
2754
|
+
};
|
|
2755
|
+
if (identifierFormat) samlConfig.identifierFormat = identifierFormat;
|
|
2756
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2757
|
+
const cookie = request.headers.get("cookie");
|
|
2758
|
+
if (cookie) headers.set("cookie", cookie);
|
|
2759
|
+
const authz = request.headers.get("authorization");
|
|
2760
|
+
if (authz) headers.set("authorization", authz);
|
|
2761
|
+
headers.set("origin", request.headers.get("origin") || origin);
|
|
2762
|
+
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
|
|
2763
|
+
const innerReq = new Request(innerUrl, {
|
|
2764
|
+
method: "POST",
|
|
2765
|
+
headers,
|
|
2766
|
+
body: JSON.stringify({ providerId, issuer, domain, samlConfig, ...organizationId ? { organizationId } : {} })
|
|
2767
|
+
});
|
|
2768
|
+
const resp = await handle(innerReq);
|
|
2769
|
+
let parsed = {};
|
|
2770
|
+
try {
|
|
2771
|
+
const t = await resp.text();
|
|
2772
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2773
|
+
} catch {
|
|
2774
|
+
parsed = {};
|
|
2775
|
+
}
|
|
2776
|
+
if (!resp.ok) {
|
|
2777
|
+
return { status: resp.status, body: { success: false, error: { code: "saml_register_failed", message: parsed?.message || "SAML provider registration failed" } } };
|
|
2778
|
+
}
|
|
2779
|
+
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } };
|
|
2780
|
+
}
|
|
2781
|
+
var SSO_DOMAIN_TOKEN_PREFIX = "better-auth-token";
|
|
2782
|
+
function bareHostname(domain) {
|
|
2783
|
+
let d = domain.trim();
|
|
2784
|
+
if (!d) return d;
|
|
2785
|
+
const schemeIdx = d.indexOf("://");
|
|
2786
|
+
if (schemeIdx !== -1) {
|
|
2787
|
+
try {
|
|
2788
|
+
return new URL(d).hostname;
|
|
2789
|
+
} catch {
|
|
2790
|
+
d = d.slice(schemeIdx + 3);
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
for (const sep of ["/", ":", "?", "#"]) {
|
|
2794
|
+
const i = d.indexOf(sep);
|
|
2795
|
+
if (i !== -1) d = d.slice(0, i);
|
|
2796
|
+
}
|
|
2797
|
+
return d;
|
|
2798
|
+
}
|
|
2799
|
+
function rewriteSsoAdminUrl(request, fromSuffix, toPath) {
|
|
2800
|
+
try {
|
|
2801
|
+
const url = new URL(request.url);
|
|
2802
|
+
return { origin: url.origin, innerUrl: `${url.origin}${url.pathname.replace(fromSuffix, toPath)}` };
|
|
2803
|
+
} catch {
|
|
2804
|
+
return null;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
function forwardAuthHeaders(request, origin) {
|
|
2808
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2809
|
+
const cookie = request.headers.get("cookie");
|
|
2810
|
+
if (cookie) headers.set("cookie", cookie);
|
|
2811
|
+
const authz = request.headers.get("authorization");
|
|
2812
|
+
if (authz) headers.set("authorization", authz);
|
|
2813
|
+
headers.set("origin", request.headers.get("origin") || origin);
|
|
2814
|
+
return headers;
|
|
2815
|
+
}
|
|
2816
|
+
async function runRequestDomainVerification(handle, request) {
|
|
2817
|
+
let body;
|
|
2818
|
+
try {
|
|
2819
|
+
body = await request.json();
|
|
2820
|
+
} catch {
|
|
2821
|
+
body = {};
|
|
2822
|
+
}
|
|
2823
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2824
|
+
const providerId = str(body?.providerId);
|
|
2825
|
+
const domain = bareHostname(str(body?.domain));
|
|
2826
|
+
if (!providerId) {
|
|
2827
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
|
|
2828
|
+
}
|
|
2829
|
+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, "/sso/request-domain-verification");
|
|
2830
|
+
if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2831
|
+
const headers = forwardAuthHeaders(request, rw.origin);
|
|
2832
|
+
const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
|
|
2833
|
+
let parsed = {};
|
|
2834
|
+
try {
|
|
2835
|
+
const t = await resp.text();
|
|
2836
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2837
|
+
} catch {
|
|
2838
|
+
parsed = {};
|
|
2839
|
+
}
|
|
2840
|
+
if (!resp.ok) {
|
|
2841
|
+
if (resp.status === 404 && !parsed?.code) {
|
|
2842
|
+
return { status: 400, body: { success: false, error: { code: "domain_verification_disabled", message: "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION)." } } };
|
|
2843
|
+
}
|
|
2844
|
+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || "request_domain_verification_failed", message: parsed?.message || "Failed to request domain verification" } } };
|
|
2845
|
+
}
|
|
2846
|
+
const token = str(parsed?.domainVerificationToken);
|
|
2847
|
+
const label = `_${SSO_DOMAIN_TOKEN_PREFIX}-${providerId}`;
|
|
2848
|
+
const dnsRecordName = domain ? `${label}.${domain}` : label;
|
|
2849
|
+
const dnsRecordValue = `${label}=${token}`;
|
|
2850
|
+
return {
|
|
2851
|
+
status: 200,
|
|
2852
|
+
body: {
|
|
2853
|
+
success: true,
|
|
2854
|
+
data: { providerId, domain, token, dnsRecordType: "TXT", dnsRecordName, dnsRecordValue }
|
|
2855
|
+
}
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
async function runVerifyDomain(handle, request) {
|
|
2859
|
+
let body;
|
|
2860
|
+
try {
|
|
2861
|
+
body = await request.json();
|
|
2862
|
+
} catch {
|
|
2863
|
+
body = {};
|
|
2864
|
+
}
|
|
2865
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2866
|
+
const providerId = str(body?.providerId);
|
|
2867
|
+
if (!providerId) {
|
|
2868
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
|
|
2869
|
+
}
|
|
2870
|
+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, "/sso/verify-domain");
|
|
2871
|
+
if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2872
|
+
const headers = forwardAuthHeaders(request, rw.origin);
|
|
2873
|
+
const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
|
|
2874
|
+
let parsed = {};
|
|
2875
|
+
try {
|
|
2876
|
+
const t = await resp.text();
|
|
2877
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2878
|
+
} catch {
|
|
2879
|
+
parsed = {};
|
|
2880
|
+
}
|
|
2881
|
+
if (resp.ok) {
|
|
2882
|
+
return { status: 200, body: { success: true, data: { providerId, verified: true, message: "Domain ownership verified \u2014 this provider can now sign users in." } } };
|
|
2883
|
+
}
|
|
2884
|
+
let message = parsed?.message || "Domain verification failed";
|
|
2885
|
+
if (resp.status === 404 && !parsed?.code) {
|
|
2886
|
+
message = "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).";
|
|
2887
|
+
} else if (parsed?.code === "NO_PENDING_VERIFICATION") {
|
|
2888
|
+
message = "No pending verification \u2014 click \u201CRequest Domain Verification\u201D first to get the DNS record.";
|
|
2889
|
+
} else if (parsed?.code === "DOMAIN_VERIFICATION_FAILED") {
|
|
2890
|
+
message = "DNS TXT record not found yet. Add the record shown when you requested verification, allow time for DNS to propagate, then retry.";
|
|
2891
|
+
}
|
|
2892
|
+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || "verify_domain_failed", message } } };
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2334
2895
|
// src/manifest.ts
|
|
2335
2896
|
var import_identity = require("@objectstack/platform-objects/identity");
|
|
2336
2897
|
var AUTH_PLUGIN_ID = "com.objectstack.plugin-auth";
|
|
@@ -2448,7 +3009,33 @@ var AuthPlugin = class {
|
|
|
2448
3009
|
// source of truth.
|
|
2449
3010
|
dashboards: [import_apps.SystemOverviewDashboard],
|
|
2450
3011
|
// ADR-0021 — datasets backing the System Overview dashboard's widgets.
|
|
2451
|
-
datasets: import_apps.SystemOverviewDatasets
|
|
3012
|
+
datasets: import_apps.SystemOverviewDatasets,
|
|
3013
|
+
// ADR-0024 / cloud#551 — surface "SSO Providers" (sys_sso_provider) in the
|
|
3014
|
+
// Setup app's Access Control group, but ONLY when the external-IdP RP is
|
|
3015
|
+
// wired (self-host `OS_SSO_ENABLED`, or the cloud per-env `planAllowsSso`
|
|
3016
|
+
// arriving via `plugins.sso`). Without the gate the entry would render an
|
|
3017
|
+
// empty list + a "Register" button whose endpoint 404s when SSO is off.
|
|
3018
|
+
// Owning-plugin-contributes pattern (ADR-0029 K2), mirroring plugin-security.
|
|
3019
|
+
...this.authManager.isSsoWired() ? {
|
|
3020
|
+
navigationContributions: [
|
|
3021
|
+
{
|
|
3022
|
+
app: "setup",
|
|
3023
|
+
group: "group_access_control",
|
|
3024
|
+
// After Roles/Permission-Sets (100) and Sharing (200), near API Keys (300).
|
|
3025
|
+
priority: 250,
|
|
3026
|
+
items: [
|
|
3027
|
+
{
|
|
3028
|
+
id: "nav_sso_providers",
|
|
3029
|
+
type: "object",
|
|
3030
|
+
label: "SSO Providers",
|
|
3031
|
+
objectName: "sys_sso_provider",
|
|
3032
|
+
icon: "log-in",
|
|
3033
|
+
requiredPermissions: ["manage_platform_settings"]
|
|
3034
|
+
}
|
|
3035
|
+
]
|
|
3036
|
+
}
|
|
3037
|
+
]
|
|
3038
|
+
} : {}
|
|
2452
3039
|
});
|
|
2453
3040
|
ctx.logger.info("Auth Plugin initialized successfully");
|
|
2454
3041
|
}
|
|
@@ -2673,6 +3260,24 @@ var AuthPlugin = class {
|
|
|
2673
3260
|
const n = Math.floor(Number(values.password_history_count));
|
|
2674
3261
|
if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
|
|
2675
3262
|
}
|
|
3263
|
+
if (isExplicit("password_expiry_days")) {
|
|
3264
|
+
const n = Math.floor(Number(values.password_expiry_days));
|
|
3265
|
+
if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n);
|
|
3266
|
+
}
|
|
3267
|
+
if (isExplicit("mfa_required")) {
|
|
3268
|
+
const on = asBoolean(values.mfa_required, false);
|
|
3269
|
+
patch.mfaRequired = on;
|
|
3270
|
+
if (on) {
|
|
3271
|
+
patch.plugins = {
|
|
3272
|
+
...patch.plugins ?? {},
|
|
3273
|
+
twoFactor: true
|
|
3274
|
+
};
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
if (isExplicit("mfa_grace_period_days")) {
|
|
3278
|
+
const n = Math.floor(Number(values.mfa_grace_period_days));
|
|
3279
|
+
if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n);
|
|
3280
|
+
}
|
|
2676
3281
|
const session = {};
|
|
2677
3282
|
if (isExplicit("session_expiry_days")) {
|
|
2678
3283
|
const d = asPositiveInt(values.session_expiry_days);
|
|
@@ -2685,6 +3290,26 @@ var AuthPlugin = class {
|
|
|
2685
3290
|
if (Object.keys(session).length > 0) {
|
|
2686
3291
|
patch.session = session;
|
|
2687
3292
|
}
|
|
3293
|
+
const asNonNeg = (v) => {
|
|
3294
|
+
const n = Math.floor(Number(v));
|
|
3295
|
+
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
3296
|
+
};
|
|
3297
|
+
if (isExplicit("session_idle_timeout_minutes")) {
|
|
3298
|
+
const n = asNonNeg(values.session_idle_timeout_minutes);
|
|
3299
|
+
if (n !== void 0) patch.sessionIdleTimeoutMinutes = n;
|
|
3300
|
+
}
|
|
3301
|
+
if (isExplicit("session_absolute_max_hours")) {
|
|
3302
|
+
const n = asNonNeg(values.session_absolute_max_hours);
|
|
3303
|
+
if (n !== void 0) patch.sessionAbsoluteMaxHours = n;
|
|
3304
|
+
}
|
|
3305
|
+
if (isExplicit("max_concurrent_sessions_per_user")) {
|
|
3306
|
+
const n = asNonNeg(values.max_concurrent_sessions_per_user);
|
|
3307
|
+
if (n !== void 0) patch.maxConcurrentSessions = n;
|
|
3308
|
+
}
|
|
3309
|
+
if (isExplicit("allowed_ip_ranges")) {
|
|
3310
|
+
const raw = asTrimmedString(values.allowed_ip_ranges) ?? "";
|
|
3311
|
+
patch.allowedIpRanges = raw.split(/[\n,]+/).map((r) => r.trim()).filter(Boolean);
|
|
3312
|
+
}
|
|
2688
3313
|
const asNonNegativeInt = (value) => {
|
|
2689
3314
|
const n = Math.floor(Number(value));
|
|
2690
3315
|
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
@@ -2833,6 +3458,21 @@ var AuthPlugin = class {
|
|
|
2833
3458
|
);
|
|
2834
3459
|
}
|
|
2835
3460
|
const rawApp = httpServer.getRawApp();
|
|
3461
|
+
if (typeof rawApp.use === "function") rawApp.use(`${basePath}/*`, async (c, next) => {
|
|
3462
|
+
const mgr = this.authManager;
|
|
3463
|
+
if (!mgr || typeof mgr.isClientIpAllowed !== "function") return next();
|
|
3464
|
+
const path = c.req.path || "";
|
|
3465
|
+
if (path.endsWith("/config") || path.endsWith("/bootstrap-status")) return next();
|
|
3466
|
+
const fwd = c.req.header("x-forwarded-for");
|
|
3467
|
+
const ip = typeof fwd === "string" && fwd.split(",")[0].trim() || c.req.header("cf-connecting-ip") || c.req.header("x-real-ip") || void 0;
|
|
3468
|
+
if (!mgr.isClientIpAllowed(ip)) {
|
|
3469
|
+
return c.json(
|
|
3470
|
+
{ success: false, error: { code: "IP_NOT_ALLOWED", message: "Sign-in is not allowed from your network." } },
|
|
3471
|
+
403
|
|
3472
|
+
);
|
|
3473
|
+
}
|
|
3474
|
+
return next();
|
|
3475
|
+
});
|
|
2836
3476
|
rawApp.get(`${basePath}/config`, async (c) => {
|
|
2837
3477
|
try {
|
|
2838
3478
|
const config = this.authManager.getPublicConfig();
|
|
@@ -2924,6 +3564,19 @@ var AuthPlugin = class {
|
|
|
2924
3564
|
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
2925
3565
|
}
|
|
2926
3566
|
});
|
|
3567
|
+
rawApp.post(`${basePath}/admin/sso/register`, async (c) => {
|
|
3568
|
+
try {
|
|
3569
|
+
const { status, body } = await runRegisterSsoProviderFromForm(
|
|
3570
|
+
(req) => this.authManager.handleRequest(req),
|
|
3571
|
+
c.req.raw
|
|
3572
|
+
);
|
|
3573
|
+
return c.json(body, status);
|
|
3574
|
+
} catch (error) {
|
|
3575
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3576
|
+
ctx.logger.error("[AuthPlugin] sso/register bridge failed", err);
|
|
3577
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3578
|
+
}
|
|
3579
|
+
});
|
|
2927
3580
|
rawApp.post(`${basePath}/admin/unlock-user`, async (c) => {
|
|
2928
3581
|
try {
|
|
2929
3582
|
let body = {};
|
|
@@ -2957,6 +3610,45 @@ var AuthPlugin = class {
|
|
|
2957
3610
|
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
2958
3611
|
}
|
|
2959
3612
|
});
|
|
3613
|
+
rawApp.post(`${basePath}/admin/sso/register-saml`, async (c) => {
|
|
3614
|
+
try {
|
|
3615
|
+
const { status, body } = await runRegisterSamlProviderFromForm(
|
|
3616
|
+
(req) => this.authManager.handleRequest(req),
|
|
3617
|
+
c.req.raw
|
|
3618
|
+
);
|
|
3619
|
+
return c.json(body, status);
|
|
3620
|
+
} catch (error) {
|
|
3621
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3622
|
+
ctx.logger.error("[AuthPlugin] sso/register-saml bridge failed", err);
|
|
3623
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3624
|
+
}
|
|
3625
|
+
});
|
|
3626
|
+
rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c) => {
|
|
3627
|
+
try {
|
|
3628
|
+
const { status, body } = await runRequestDomainVerification(
|
|
3629
|
+
(req) => this.authManager.handleRequest(req),
|
|
3630
|
+
c.req.raw
|
|
3631
|
+
);
|
|
3632
|
+
return c.json(body, status);
|
|
3633
|
+
} catch (error) {
|
|
3634
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3635
|
+
ctx.logger.error("[AuthPlugin] sso/request-domain-verification bridge failed", err);
|
|
3636
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3637
|
+
}
|
|
3638
|
+
});
|
|
3639
|
+
rawApp.post(`${basePath}/admin/sso/verify-domain`, async (c) => {
|
|
3640
|
+
try {
|
|
3641
|
+
const { status, body } = await runVerifyDomain(
|
|
3642
|
+
(req) => this.authManager.handleRequest(req),
|
|
3643
|
+
c.req.raw
|
|
3644
|
+
);
|
|
3645
|
+
return c.json(body, status);
|
|
3646
|
+
} catch (error) {
|
|
3647
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3648
|
+
ctx.logger.error("[AuthPlugin] sso/verify-domain bridge failed", err);
|
|
3649
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3650
|
+
}
|
|
3651
|
+
});
|
|
2960
3652
|
rawApp.post(`${basePath}/sys-oauth-application/register`, async (c) => {
|
|
2961
3653
|
try {
|
|
2962
3654
|
let body = {};
|
|
@@ -3123,8 +3815,13 @@ var AuthPlugin = class {
|
|
|
3123
3815
|
buildTwoFactorPluginSchema,
|
|
3124
3816
|
createObjectQLAdapter,
|
|
3125
3817
|
createObjectQLAdapterFactory,
|
|
3818
|
+
ipMatchesRange,
|
|
3126
3819
|
resolveProtocolName,
|
|
3820
|
+
runRegisterSamlProviderFromForm,
|
|
3821
|
+
runRegisterSsoProviderFromForm,
|
|
3822
|
+
runRequestDomainVerification,
|
|
3127
3823
|
runSetInitialPassword,
|
|
3824
|
+
runVerifyDomain,
|
|
3128
3825
|
withSystemReadContext
|
|
3129
3826
|
});
|
|
3130
3827
|
//# sourceMappingURL=index.js.map
|