@objectstack/plugin-auth 11.0.0 → 11.2.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.mjs
CHANGED
|
@@ -522,7 +522,13 @@ var AUTH_SSO_PROVIDER_SCHEMA = {
|
|
|
522
522
|
oidcConfig: "oidc_config",
|
|
523
523
|
samlConfig: "saml_config",
|
|
524
524
|
userId: "user_id",
|
|
525
|
-
organizationId: "organization_id"
|
|
525
|
+
organizationId: "organization_id",
|
|
526
|
+
// DNS domain-ownership proof (ADR-0024 ②). @better-auth/sso writes
|
|
527
|
+
// `domainVerified` on its `ssoProvider` model when domain verification is
|
|
528
|
+
// enabled; map it so the env can surface a verified/unverified badge. The
|
|
529
|
+
// one-time `domainVerificationToken` is NOT a provider column — it lives in
|
|
530
|
+
// the verification table and is returned only from request-domain-verification.
|
|
531
|
+
domainVerified: "domain_verified"
|
|
526
532
|
}
|
|
527
533
|
};
|
|
528
534
|
var AUTH_SCIM_PROVIDER_SCHEMA = {
|
|
@@ -601,9 +607,36 @@ function readDisableSignUpEnv() {
|
|
|
601
607
|
function readSsoOnlyEnv() {
|
|
602
608
|
return readBooleanEnv("OS_AUTH_SSO_ONLY");
|
|
603
609
|
}
|
|
610
|
+
function ipv4ToInt(ip) {
|
|
611
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim());
|
|
612
|
+
if (!m) return null;
|
|
613
|
+
const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
614
|
+
if (p.some((n) => n > 255)) return null;
|
|
615
|
+
return (p[0] << 24 >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3] >>> 0;
|
|
616
|
+
}
|
|
617
|
+
function ipMatchesRange(ip, range) {
|
|
618
|
+
const r = (range || "").trim();
|
|
619
|
+
if (!r) return false;
|
|
620
|
+
if (r.includes("/")) {
|
|
621
|
+
const [base, bitsStr] = r.split("/");
|
|
622
|
+
const bits = Number(bitsStr);
|
|
623
|
+
const ipInt = ipv4ToInt(ip);
|
|
624
|
+
const baseInt = ipv4ToInt(base);
|
|
625
|
+
if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) {
|
|
626
|
+
return ip.trim() === base.trim();
|
|
627
|
+
}
|
|
628
|
+
const mask = bits === 0 ? 0 : ~0 << 32 - bits >>> 0;
|
|
629
|
+
return (ipInt & mask) >>> 0 === (baseInt & mask) >>> 0;
|
|
630
|
+
}
|
|
631
|
+
return ip.trim() === r;
|
|
632
|
+
}
|
|
604
633
|
var AuthManager = class {
|
|
605
634
|
constructor(config) {
|
|
606
635
|
this.auth = null;
|
|
636
|
+
// ADR-0069 — cached "does any org require MFA" flag (per-org tightening).
|
|
637
|
+
// Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap.
|
|
638
|
+
this._orgMfaCache = { value: false, at: 0 };
|
|
639
|
+
this._orgMfaRefreshing = false;
|
|
607
640
|
this.config = config;
|
|
608
641
|
installWebContainerRequestStatePolyfill();
|
|
609
642
|
if (config.authInstance) {
|
|
@@ -808,6 +841,7 @@ var AuthManager = class {
|
|
|
808
841
|
if (candidate && (ctx?.path === "/reset-password" || ctx?.path === "/change-password")) {
|
|
809
842
|
const userId = await this.resolvePasswordChangeUserId(ctx).catch(() => void 0);
|
|
810
843
|
if (userId) {
|
|
844
|
+
ctx.context.__osPwChangeUserId = userId;
|
|
811
845
|
const pw = ctx?.context?.password;
|
|
812
846
|
const verify = typeof pw?.verify === "function" ? pw.verify.bind(pw) : void 0;
|
|
813
847
|
const oldHash = await this.assertPasswordNotReused(userId, candidate, verify);
|
|
@@ -947,25 +981,43 @@ var AuthManager = class {
|
|
|
947
981
|
succeeded = !(ctx?.context?.returned instanceof Error);
|
|
948
982
|
}
|
|
949
983
|
await this.recordSignInOutcome(email, succeeded);
|
|
984
|
+
if (succeeded) {
|
|
985
|
+
const uid = ctx?.context?.returned?.user?.id;
|
|
986
|
+
if (typeof uid === "string") await this.enforceConcurrentCap(uid);
|
|
987
|
+
}
|
|
950
988
|
}
|
|
951
989
|
return;
|
|
952
990
|
}
|
|
953
991
|
if (ctx?.path === "/change-password" || ctx?.path === "/reset-password") {
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
} catch {
|
|
961
|
-
succeeded = !(ctx?.context?.returned instanceof Error);
|
|
962
|
-
}
|
|
963
|
-
if (succeeded) await this.recordPasswordHistory(stash.userId, stash.oldHash);
|
|
964
|
-
delete ctx.context.__osPwHistory;
|
|
992
|
+
let succeeded;
|
|
993
|
+
try {
|
|
994
|
+
const { isAPIError } = await import("better-auth/api");
|
|
995
|
+
succeeded = !isAPIError(ctx?.context?.returned);
|
|
996
|
+
} catch {
|
|
997
|
+
succeeded = !(ctx?.context?.returned instanceof Error);
|
|
965
998
|
}
|
|
999
|
+
if (succeeded) {
|
|
1000
|
+
const stampId = ctx?.context?.__osPwChangeUserId;
|
|
1001
|
+
if (stampId) await this.stampPasswordChangedAt(stampId);
|
|
1002
|
+
const stash = ctx?.context?.__osPwHistory;
|
|
1003
|
+
if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
|
|
1004
|
+
}
|
|
1005
|
+
delete ctx.context.__osPwChangeUserId;
|
|
1006
|
+
delete ctx.context.__osPwHistory;
|
|
966
1007
|
return;
|
|
967
1008
|
}
|
|
968
1009
|
if (ctx?.path !== "/sign-up/email") return;
|
|
1010
|
+
{
|
|
1011
|
+
const newUserId = ctx?.context?.returned?.user?.id;
|
|
1012
|
+
let signupOk;
|
|
1013
|
+
try {
|
|
1014
|
+
const { isAPIError } = await import("better-auth/api");
|
|
1015
|
+
signupOk = !isAPIError(ctx?.context?.returned);
|
|
1016
|
+
} catch {
|
|
1017
|
+
signupOk = !(ctx?.context?.returned instanceof Error);
|
|
1018
|
+
}
|
|
1019
|
+
if (signupOk && typeof newUserId === "string") await this.stampPasswordChangedAt(newUserId);
|
|
1020
|
+
}
|
|
969
1021
|
const ep = ctx?.context?.options?.emailAndPassword;
|
|
970
1022
|
if (ep && ctx.context.__osDisableSignUpOrig !== void 0) {
|
|
971
1023
|
ep.disableSignUp = ctx.context.__osDisableSignUpOrig;
|
|
@@ -977,7 +1029,7 @@ var AuthManager = class {
|
|
|
977
1029
|
// Auto-includes origins from OS_CORS_ORIGIN env var so CORS and CSRF stay in sync.
|
|
978
1030
|
...(() => {
|
|
979
1031
|
const origins = [...this.config.trustedOrigins || []];
|
|
980
|
-
const corsOrigin = readEnvWithDeprecation("OS_CORS_ORIGIN", "CORS_ORIGIN");
|
|
1032
|
+
const corsOrigin = readEnvWithDeprecation("OS_CORS_ORIGIN", "CORS_ORIGIN", { silent: true });
|
|
981
1033
|
if (corsOrigin && corsOrigin !== "*") {
|
|
982
1034
|
corsOrigin.split(",").map((s) => s.trim()).filter(Boolean).forEach((o) => {
|
|
983
1035
|
if (!origins.includes(o)) origins.push(o);
|
|
@@ -1077,12 +1129,10 @@ var AuthManager = class {
|
|
|
1077
1129
|
async buildPluginList() {
|
|
1078
1130
|
const pluginConfig = this.config.plugins ?? {};
|
|
1079
1131
|
const plugins = [];
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
1082
|
-
const
|
|
1083
|
-
const
|
|
1084
|
-
const scimEnv = globalThis?.process?.env?.OS_SCIM_ENABLED;
|
|
1085
|
-
const scimFromEnv = scimEnv != null ? String(scimEnv).toLowerCase() === "true" : void 0;
|
|
1132
|
+
const oidcFromEnv = readBooleanEnv("OS_OIDC_PROVIDER_ENABLED");
|
|
1133
|
+
const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
|
|
1134
|
+
const scimFromEnv = readBooleanEnv("OS_SCIM_ENABLED");
|
|
1135
|
+
const ssoDomainVerifyFromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
|
|
1086
1136
|
const scimEffective = scimFromEnv ?? pluginConfig.scim ?? false;
|
|
1087
1137
|
const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
|
|
1088
1138
|
const hibpFromEnv = readBooleanEnv("OS_AUTH_PASSWORD_REJECT_BREACHED");
|
|
@@ -1096,6 +1146,7 @@ var AuthManager = class {
|
|
|
1096
1146
|
deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
|
|
1097
1147
|
admin: pluginConfig.admin ?? scimEffective,
|
|
1098
1148
|
sso: ssoFromEnv ?? pluginConfig.sso ?? false,
|
|
1149
|
+
ssoDomainVerification: ssoDomainVerifyFromEnv ?? pluginConfig.ssoDomainVerification ?? false,
|
|
1099
1150
|
scim: scimEffective
|
|
1100
1151
|
};
|
|
1101
1152
|
const { bearer } = await import("better-auth/plugins/bearer");
|
|
@@ -1179,9 +1230,8 @@ var AuthManager = class {
|
|
|
1179
1230
|
// The plugin itself is always installed (so list/update/invite endpoints
|
|
1180
1231
|
// keep responding); only the `create` operation is denied when the
|
|
1181
1232
|
// deployment is provisioned in single-org mode. Resolution order:
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
// multi-org), default `'false'` → single-org / per-env runtime.
|
|
1233
|
+
// `OS_MULTI_ORG_ENABLED` (default `'false'` → single-org /
|
|
1234
|
+
// per-env runtime).
|
|
1185
1235
|
beforeCreateOrganization: async () => {
|
|
1186
1236
|
if (!resolveMultiOrgEnabled()) {
|
|
1187
1237
|
const { APIError } = await import("better-auth/api");
|
|
@@ -1362,7 +1412,8 @@ var AuthManager = class {
|
|
|
1362
1412
|
if (enabled.sso) {
|
|
1363
1413
|
const { sso } = await import("@better-auth/sso");
|
|
1364
1414
|
plugins.push(sso({
|
|
1365
|
-
organizationProvisioning: { defaultRole: "member" }
|
|
1415
|
+
organizationProvisioning: { defaultRole: "member" },
|
|
1416
|
+
...enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}
|
|
1366
1417
|
}));
|
|
1367
1418
|
}
|
|
1368
1419
|
if (enabled.scim) {
|
|
@@ -1434,7 +1485,16 @@ var AuthManager = class {
|
|
|
1434
1485
|
...orgRoles,
|
|
1435
1486
|
...platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []
|
|
1436
1487
|
]));
|
|
1437
|
-
|
|
1488
|
+
await this.enforceSessionControls(session?.id, session?.createdAt);
|
|
1489
|
+
const authGate = await this.computeAuthGate(
|
|
1490
|
+
user.id,
|
|
1491
|
+
session?.activeOrganizationId,
|
|
1492
|
+
user?.twoFactorEnabled === true
|
|
1493
|
+
);
|
|
1494
|
+
return {
|
|
1495
|
+
user: { ...user, roles, isPlatformAdmin: platformAdmin, ...authGate ? { authGate } : {} },
|
|
1496
|
+
session
|
|
1497
|
+
};
|
|
1438
1498
|
}));
|
|
1439
1499
|
}
|
|
1440
1500
|
return plugins;
|
|
@@ -1464,7 +1524,7 @@ var AuthManager = class {
|
|
|
1464
1524
|
* Generate a secure secret if not provided
|
|
1465
1525
|
*/
|
|
1466
1526
|
generateSecret() {
|
|
1467
|
-
const envSecret = readEnvWithDeprecation("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"]);
|
|
1527
|
+
const envSecret = readEnvWithDeprecation("OS_AUTH_SECRET", ["AUTH_SECRET", "BETTER_AUTH_SECRET"], { silent: true });
|
|
1468
1528
|
if (envSecret) return envSecret;
|
|
1469
1529
|
if (process.env.NODE_ENV === "production") {
|
|
1470
1530
|
throw new Error(
|
|
@@ -1739,12 +1799,28 @@ var AuthManager = class {
|
|
|
1739
1799
|
* in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
|
|
1740
1800
|
* advertised capability can never disagree with the actual `/sign-in/sso`
|
|
1741
1801
|
* route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
|
|
1802
|
+
* Public so `AuthPlugin` can gate the Setup-nav "SSO Providers" entry on it
|
|
1803
|
+
* (captures both self-host `OS_SSO_ENABLED` and the cloud per-env
|
|
1804
|
+
* `planAllowsSso` config, since that arrives via `plugins.sso`).
|
|
1742
1805
|
*/
|
|
1743
1806
|
isSsoWired() {
|
|
1744
|
-
const
|
|
1745
|
-
const ssoFromEnv = ssoEnv != null ? String(ssoEnv).toLowerCase() === "true" : void 0;
|
|
1807
|
+
const ssoFromEnv = readBooleanEnv("OS_SSO_ENABLED");
|
|
1746
1808
|
return ssoFromEnv ?? this.config.plugins?.sso ?? false;
|
|
1747
1809
|
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Whether opt-in DNS domain-verification (ADR-0024 ②) is wired — i.e. the
|
|
1812
|
+
* `/sso/request-domain-verification` + `/sso/verify-domain` endpoints are
|
|
1813
|
+
* mounted (and the hard "domain must be verified to log in" gate is active).
|
|
1814
|
+
* Resolved with the EXACT logic `buildPluginList` uses for the `sso()`
|
|
1815
|
+
* `domainVerification.enabled` option, so the bridge can return a clear
|
|
1816
|
+
* "not enabled for this environment" instead of a bare 404 when off.
|
|
1817
|
+
* Implies `isSsoWired()` (the sso plugin must be loaded to honor it).
|
|
1818
|
+
*/
|
|
1819
|
+
isSsoDomainVerificationEnabled() {
|
|
1820
|
+
if (!this.isSsoWired()) return false;
|
|
1821
|
+
const fromEnv = readBooleanEnv("OS_SSO_DOMAIN_VERIFICATION");
|
|
1822
|
+
return fromEnv ?? this.config.plugins?.ssoDomainVerification ?? false;
|
|
1823
|
+
}
|
|
1748
1824
|
/**
|
|
1749
1825
|
* Whether enterprise SSO is actually *usable*, not merely wired: the plugin
|
|
1750
1826
|
* is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
|
|
@@ -2004,6 +2080,119 @@ var AuthManager = class {
|
|
|
2004
2080
|
});
|
|
2005
2081
|
}
|
|
2006
2082
|
}
|
|
2083
|
+
/**
|
|
2084
|
+
* ADR-0069 — is any authentication-policy gate enabled? Cheap, synchronous;
|
|
2085
|
+
* lets the transport seams skip session lookups entirely when off (the
|
|
2086
|
+
* default), keeping the gate zero-overhead until an admin opts in.
|
|
2087
|
+
*/
|
|
2088
|
+
isAuthGateActive() {
|
|
2089
|
+
this.refreshOrgMfaCacheIfStale();
|
|
2090
|
+
return Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || this.config.mfaRequired === true || this._orgMfaCache.value;
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* ADR-0069 — refresh the "any org requires MFA" cache in the background when
|
|
2094
|
+
* stale (60s TTL). Fire-and-forget: a brand-new per-org requirement activates
|
|
2095
|
+
* the gate on the next request, never blocking this one. No-op when global MFA
|
|
2096
|
+
* is already on (the gate is active regardless).
|
|
2097
|
+
*/
|
|
2098
|
+
refreshOrgMfaCacheIfStale() {
|
|
2099
|
+
if (this.config.mfaRequired === true) return;
|
|
2100
|
+
if (this._orgMfaRefreshing) return;
|
|
2101
|
+
if (Date.now() - this._orgMfaCache.at < 6e4) return;
|
|
2102
|
+
const engine = this.getDataEngine();
|
|
2103
|
+
if (!engine) return;
|
|
2104
|
+
this._orgMfaRefreshing = true;
|
|
2105
|
+
void (async () => {
|
|
2106
|
+
try {
|
|
2107
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2108
|
+
const n = await engine.count("sys_organization", {
|
|
2109
|
+
where: { require_mfa: true },
|
|
2110
|
+
context: SYSTEM_CTX
|
|
2111
|
+
});
|
|
2112
|
+
this._orgMfaCache = { value: typeof n === "number" && n > 0, at: Date.now() };
|
|
2113
|
+
} catch {
|
|
2114
|
+
} finally {
|
|
2115
|
+
this._orgMfaRefreshing = false;
|
|
2116
|
+
}
|
|
2117
|
+
})();
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* ADR-0069 — compute the auth-policy gate posture for a session. Returns an
|
|
2121
|
+
* `{ code, message }` when the user is currently blocked (e.g. password
|
|
2122
|
+
* expired), else undefined. No-op (and no DB read) when no gate feature is
|
|
2123
|
+
* enabled. Fails OPEN on any lookup error — a transient hiccup must never lock
|
|
2124
|
+
* a compliant user out.
|
|
2125
|
+
*/
|
|
2126
|
+
async computeAuthGate(userId, _activeOrgId, _twoFactorEnabledHint) {
|
|
2127
|
+
const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0);
|
|
2128
|
+
const mfaGlobal = this.config.mfaRequired === true;
|
|
2129
|
+
const orgMaybeRequires = !mfaGlobal && !!_activeOrgId && this._orgMfaCache.value;
|
|
2130
|
+
if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires) return void 0;
|
|
2131
|
+
const engine = this.getDataEngine();
|
|
2132
|
+
if (!engine || !userId) return void 0;
|
|
2133
|
+
try {
|
|
2134
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2135
|
+
const u = await engine.findOne("sys_user", {
|
|
2136
|
+
where: { id: userId },
|
|
2137
|
+
fields: ["password_changed_at", "two_factor_enabled", "mfa_required_at"],
|
|
2138
|
+
context: SYSTEM_CTX
|
|
2139
|
+
});
|
|
2140
|
+
let mfaRequired = mfaGlobal;
|
|
2141
|
+
if (!mfaRequired && orgMaybeRequires) {
|
|
2142
|
+
const org = await engine.findOne("sys_organization", {
|
|
2143
|
+
where: { id: _activeOrgId },
|
|
2144
|
+
fields: ["require_mfa"],
|
|
2145
|
+
context: SYSTEM_CTX
|
|
2146
|
+
});
|
|
2147
|
+
mfaRequired = org?.require_mfa === true || org?.require_mfa === 1;
|
|
2148
|
+
}
|
|
2149
|
+
if (expiryDays > 0) {
|
|
2150
|
+
const changed = u?.password_changed_at;
|
|
2151
|
+
if (changed && Date.now() - new Date(changed).getTime() > expiryDays * 864e5) {
|
|
2152
|
+
return {
|
|
2153
|
+
code: "PASSWORD_EXPIRED",
|
|
2154
|
+
message: "Your password has expired. Please change it to continue."
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
if (mfaRequired && !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1)) {
|
|
2159
|
+
const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7)));
|
|
2160
|
+
let requiredAt = u?.mfa_required_at;
|
|
2161
|
+
if (!requiredAt) {
|
|
2162
|
+
requiredAt = /* @__PURE__ */ new Date();
|
|
2163
|
+
engine.update("sys_user", { id: userId, mfa_required_at: requiredAt }, { context: SYSTEM_CTX }).catch(() => void 0);
|
|
2164
|
+
}
|
|
2165
|
+
const elapsedMs = Date.now() - new Date(requiredAt).getTime();
|
|
2166
|
+
if (elapsedMs > graceDays * 864e5) {
|
|
2167
|
+
return {
|
|
2168
|
+
code: "MFA_REQUIRED",
|
|
2169
|
+
message: "Multi-factor authentication is required. Please set up an authenticator app to continue."
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
} catch {
|
|
2174
|
+
return void 0;
|
|
2175
|
+
}
|
|
2176
|
+
return void 0;
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* ADR-0069 D1 — stamp `sys_user.password_changed_at = now` after a password is
|
|
2180
|
+
* set (sign-up / change / reset). Best-effort; never throws. Written as a Date
|
|
2181
|
+
* (never epoch-ms) per ADR-0074.
|
|
2182
|
+
*/
|
|
2183
|
+
async stampPasswordChangedAt(userId) {
|
|
2184
|
+
const engine = this.getDataEngine();
|
|
2185
|
+
if (!engine || !userId) return;
|
|
2186
|
+
try {
|
|
2187
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2188
|
+
await engine.update(
|
|
2189
|
+
"sys_user",
|
|
2190
|
+
{ id: userId, password_changed_at: /* @__PURE__ */ new Date() },
|
|
2191
|
+
{ context: SYSTEM_CTX }
|
|
2192
|
+
);
|
|
2193
|
+
} catch {
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2007
2196
|
/**
|
|
2008
2197
|
* ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
|
|
2009
2198
|
* a string[] of hashes, tolerating null / malformed values.
|
|
@@ -2220,6 +2409,96 @@ var AuthManager = class {
|
|
|
2220
2409
|
);
|
|
2221
2410
|
return true;
|
|
2222
2411
|
}
|
|
2412
|
+
/**
|
|
2413
|
+
* ADR-0069 D4 — idle / absolute session enforcement, run per request from
|
|
2414
|
+
* `customSession`. No-op when both are off. Revokes (expires in place +
|
|
2415
|
+
* stamps revoked_at/revoke_reason) when a limit is exceeded so better-auth
|
|
2416
|
+
* returns no session on the NEXT request; otherwise touches `last_activity_at`
|
|
2417
|
+
* (throttled to once a minute). Best-effort — never throws.
|
|
2418
|
+
*/
|
|
2419
|
+
async enforceSessionControls(sessionId, createdAtHint) {
|
|
2420
|
+
const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0);
|
|
2421
|
+
const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0);
|
|
2422
|
+
if (idleMin <= 0 && absHrs <= 0) return;
|
|
2423
|
+
const engine = this.getDataEngine();
|
|
2424
|
+
if (!engine || !sessionId) return;
|
|
2425
|
+
try {
|
|
2426
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2427
|
+
const srow = await engine.findOne("sys_session", {
|
|
2428
|
+
where: { id: sessionId },
|
|
2429
|
+
fields: ["id", "created_at", "last_activity_at", "revoked_at"],
|
|
2430
|
+
context: SYSTEM_CTX
|
|
2431
|
+
});
|
|
2432
|
+
if (!srow?.id || srow.revoked_at) return;
|
|
2433
|
+
const now = Date.now();
|
|
2434
|
+
let reason;
|
|
2435
|
+
if (absHrs > 0) {
|
|
2436
|
+
const created = srow.created_at ?? createdAtHint;
|
|
2437
|
+
if (created && now - new Date(created).getTime() > absHrs * 36e5) reason = "absolute_max";
|
|
2438
|
+
}
|
|
2439
|
+
if (!reason && idleMin > 0) {
|
|
2440
|
+
const last = srow.last_activity_at ?? srow.created_at ?? createdAtHint;
|
|
2441
|
+
if (last && now - new Date(last).getTime() > idleMin * 6e4) reason = "idle_timeout";
|
|
2442
|
+
}
|
|
2443
|
+
if (reason) {
|
|
2444
|
+
await engine.update(
|
|
2445
|
+
"sys_session",
|
|
2446
|
+
{ id: sessionId, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: reason },
|
|
2447
|
+
{ context: SYSTEM_CTX }
|
|
2448
|
+
).catch(() => void 0);
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
if (idleMin > 0) {
|
|
2452
|
+
const la = srow.last_activity_at ? new Date(srow.last_activity_at).getTime() : 0;
|
|
2453
|
+
if (now - la > 6e4) {
|
|
2454
|
+
await engine.update("sys_session", { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX }).catch(() => void 0);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
} catch {
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* ADR-0069 D4 — concurrent-session cap, run from the sign-in after-hook.
|
|
2462
|
+
* Keeps the newest `maxConcurrentSessions` live sessions for the user and
|
|
2463
|
+
* revokes the rest (oldest first). No-op when off. Best-effort.
|
|
2464
|
+
*/
|
|
2465
|
+
async enforceConcurrentCap(userId) {
|
|
2466
|
+
const cap = Math.floor(Number(this.config.maxConcurrentSessions) || 0);
|
|
2467
|
+
if (cap <= 0 || !userId) return;
|
|
2468
|
+
const engine = this.getDataEngine();
|
|
2469
|
+
if (!engine) return;
|
|
2470
|
+
try {
|
|
2471
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
2472
|
+
const rows = await engine.find("sys_session", {
|
|
2473
|
+
where: { user_id: userId },
|
|
2474
|
+
fields: ["id", "created_at", "expires_at", "revoked_at"],
|
|
2475
|
+
limit: 200,
|
|
2476
|
+
context: SYSTEM_CTX
|
|
2477
|
+
});
|
|
2478
|
+
const now = Date.now();
|
|
2479
|
+
const live = (Array.isArray(rows) ? rows : []).filter((sn) => !sn.revoked_at && (!sn.expires_at || new Date(sn.expires_at).getTime() > now)).sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
|
2480
|
+
for (const sn of live.slice(cap)) {
|
|
2481
|
+
await engine.update(
|
|
2482
|
+
"sys_session",
|
|
2483
|
+
{ id: sn.id, expires_at: new Date(now - 1e3), revoked_at: new Date(now), revoke_reason: "concurrent_cap" },
|
|
2484
|
+
{ context: SYSTEM_CTX }
|
|
2485
|
+
).catch(() => void 0);
|
|
2486
|
+
}
|
|
2487
|
+
} catch {
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no
|
|
2492
|
+
* ranges are configured, OR when the IP can't be determined (fail-open so a
|
|
2493
|
+
* misconfigured proxy never locks everyone out — an admin enabling this must
|
|
2494
|
+
* ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6.
|
|
2495
|
+
*/
|
|
2496
|
+
isClientIpAllowed(ip) {
|
|
2497
|
+
const ranges = this.config.allowedIpRanges;
|
|
2498
|
+
if (!ranges || ranges.length === 0) return true;
|
|
2499
|
+
if (!ip) return true;
|
|
2500
|
+
return ranges.some((r) => ipMatchesRange(ip, r));
|
|
2501
|
+
}
|
|
2223
2502
|
/**
|
|
2224
2503
|
* Returns the data engine wired into this auth manager. Used by route
|
|
2225
2504
|
* handlers (e.g. bootstrap-status) that need to query identity tables
|
|
@@ -2261,6 +2540,283 @@ function mapSetPasswordError(error) {
|
|
|
2261
2540
|
return { status, body: { success: false, error: { code, message } } };
|
|
2262
2541
|
}
|
|
2263
2542
|
|
|
2543
|
+
// src/register-sso-provider.ts
|
|
2544
|
+
async function resolveActiveOrganizationId(handle, registerUrl, headers) {
|
|
2545
|
+
try {
|
|
2546
|
+
const sessionUrl = registerUrl.replace(/\/sso\/register$/, "/get-session");
|
|
2547
|
+
if (sessionUrl === registerUrl) return void 0;
|
|
2548
|
+
const h = new Headers({ accept: "application/json" });
|
|
2549
|
+
const cookie = headers.get("cookie");
|
|
2550
|
+
if (cookie) h.set("cookie", cookie);
|
|
2551
|
+
const authz = headers.get("authorization");
|
|
2552
|
+
if (authz) h.set("authorization", authz);
|
|
2553
|
+
const resp = await handle(new Request(sessionUrl, { method: "GET", headers: h }));
|
|
2554
|
+
if (!resp.ok) return void 0;
|
|
2555
|
+
const data = await resp.json().catch(() => null);
|
|
2556
|
+
const org = data?.session?.activeOrganizationId ?? data?.activeOrganizationId;
|
|
2557
|
+
return typeof org === "string" && org.length > 0 ? org : void 0;
|
|
2558
|
+
} catch {
|
|
2559
|
+
return void 0;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
async function runRegisterSsoProviderFromForm(handle, request) {
|
|
2563
|
+
let body;
|
|
2564
|
+
try {
|
|
2565
|
+
body = await request.json();
|
|
2566
|
+
} catch {
|
|
2567
|
+
body = {};
|
|
2568
|
+
}
|
|
2569
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2570
|
+
const providerId = str(body?.providerId);
|
|
2571
|
+
const issuer = str(body?.issuer);
|
|
2572
|
+
const domain = str(body?.domain);
|
|
2573
|
+
const clientId = str(body?.clientId);
|
|
2574
|
+
const clientSecret = str(body?.clientSecret);
|
|
2575
|
+
const discoveryEndpoint = str(body?.discoveryEndpoint);
|
|
2576
|
+
const scopesRaw = str(body?.scopes);
|
|
2577
|
+
const missing = [
|
|
2578
|
+
["providerId", providerId],
|
|
2579
|
+
["issuer", issuer],
|
|
2580
|
+
["domain", domain],
|
|
2581
|
+
["clientId", clientId],
|
|
2582
|
+
["clientSecret", clientSecret]
|
|
2583
|
+
].filter(([, v]) => !v).map(([k]) => k);
|
|
2584
|
+
if (missing.length) {
|
|
2585
|
+
return {
|
|
2586
|
+
status: 400,
|
|
2587
|
+
body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } }
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
const oidcConfig = { clientId, clientSecret };
|
|
2591
|
+
if (discoveryEndpoint) oidcConfig.discoveryEndpoint = discoveryEndpoint;
|
|
2592
|
+
oidcConfig.scopes = scopesRaw ? scopesRaw.split(/[\s,]+/).filter(Boolean) : ["openid", "email", "profile"];
|
|
2593
|
+
oidcConfig.mapping = {
|
|
2594
|
+
id: str(body?.mapId) || "sub",
|
|
2595
|
+
email: str(body?.mapEmail) || "email",
|
|
2596
|
+
name: str(body?.mapName) || "name"
|
|
2597
|
+
};
|
|
2598
|
+
let innerUrl;
|
|
2599
|
+
let origin;
|
|
2600
|
+
try {
|
|
2601
|
+
const url = new URL(request.url);
|
|
2602
|
+
origin = url.origin;
|
|
2603
|
+
innerUrl = `${origin}${url.pathname.replace(/\/admin\/sso\/register$/, "/sso/register")}`;
|
|
2604
|
+
} catch {
|
|
2605
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2606
|
+
}
|
|
2607
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2608
|
+
const cookie = request.headers.get("cookie");
|
|
2609
|
+
if (cookie) headers.set("cookie", cookie);
|
|
2610
|
+
const authz = request.headers.get("authorization");
|
|
2611
|
+
if (authz) headers.set("authorization", authz);
|
|
2612
|
+
headers.set("origin", request.headers.get("origin") || origin);
|
|
2613
|
+
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
|
|
2614
|
+
const innerReq = new Request(innerUrl, {
|
|
2615
|
+
method: "POST",
|
|
2616
|
+
headers,
|
|
2617
|
+
body: JSON.stringify({ providerId, issuer, domain, oidcConfig, ...organizationId ? { organizationId } : {} })
|
|
2618
|
+
});
|
|
2619
|
+
const resp = await handle(innerReq);
|
|
2620
|
+
let parsed = {};
|
|
2621
|
+
try {
|
|
2622
|
+
const t = await resp.text();
|
|
2623
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2624
|
+
} catch {
|
|
2625
|
+
parsed = {};
|
|
2626
|
+
}
|
|
2627
|
+
if (!resp.ok) {
|
|
2628
|
+
return {
|
|
2629
|
+
status: resp.status,
|
|
2630
|
+
body: { success: false, error: { code: "sso_register_failed", message: parsed?.message || "SSO provider registration failed" } }
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId } } };
|
|
2634
|
+
}
|
|
2635
|
+
async function runRegisterSamlProviderFromForm(handle, request) {
|
|
2636
|
+
let body;
|
|
2637
|
+
try {
|
|
2638
|
+
body = await request.json();
|
|
2639
|
+
} catch {
|
|
2640
|
+
body = {};
|
|
2641
|
+
}
|
|
2642
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2643
|
+
const providerId = str(body?.providerId);
|
|
2644
|
+
const issuer = str(body?.issuer);
|
|
2645
|
+
const domain = str(body?.domain);
|
|
2646
|
+
const entryPoint = str(body?.entryPoint);
|
|
2647
|
+
const cert = str(body?.cert);
|
|
2648
|
+
const identifierFormat = str(body?.identifierFormat);
|
|
2649
|
+
const missing = [
|
|
2650
|
+
["providerId", providerId],
|
|
2651
|
+
["issuer", issuer],
|
|
2652
|
+
["domain", domain],
|
|
2653
|
+
["entryPoint", entryPoint],
|
|
2654
|
+
["cert", cert]
|
|
2655
|
+
].filter(([, v]) => !v).map(([k]) => k);
|
|
2656
|
+
if (missing.length) {
|
|
2657
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: `Missing required field(s): ${missing.join(", ")}` } } };
|
|
2658
|
+
}
|
|
2659
|
+
let origin;
|
|
2660
|
+
let prefix;
|
|
2661
|
+
let innerUrl;
|
|
2662
|
+
try {
|
|
2663
|
+
const url = new URL(request.url);
|
|
2664
|
+
origin = url.origin;
|
|
2665
|
+
prefix = url.pathname.replace(/\/admin\/sso\/register-saml$/, "");
|
|
2666
|
+
innerUrl = `${origin}${prefix}/sso/register`;
|
|
2667
|
+
} catch {
|
|
2668
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2669
|
+
}
|
|
2670
|
+
const acsUrl = `${origin}${prefix}/sso/saml2/sp/acs/${encodeURIComponent(providerId)}`;
|
|
2671
|
+
const spMetadataUrl = `${origin}${prefix}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}`;
|
|
2672
|
+
const samlConfig = {
|
|
2673
|
+
entryPoint,
|
|
2674
|
+
cert,
|
|
2675
|
+
callbackUrl: acsUrl,
|
|
2676
|
+
// better-auth requires an SP descriptor (its inner fields are optional). Use
|
|
2677
|
+
// the SP metadata URL as our EntityID — the value the IdP keys this SP on.
|
|
2678
|
+
spMetadata: { entityID: spMetadataUrl }
|
|
2679
|
+
};
|
|
2680
|
+
if (identifierFormat) samlConfig.identifierFormat = identifierFormat;
|
|
2681
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2682
|
+
const cookie = request.headers.get("cookie");
|
|
2683
|
+
if (cookie) headers.set("cookie", cookie);
|
|
2684
|
+
const authz = request.headers.get("authorization");
|
|
2685
|
+
if (authz) headers.set("authorization", authz);
|
|
2686
|
+
headers.set("origin", request.headers.get("origin") || origin);
|
|
2687
|
+
const organizationId = await resolveActiveOrganizationId(handle, innerUrl, headers);
|
|
2688
|
+
const innerReq = new Request(innerUrl, {
|
|
2689
|
+
method: "POST",
|
|
2690
|
+
headers,
|
|
2691
|
+
body: JSON.stringify({ providerId, issuer, domain, samlConfig, ...organizationId ? { organizationId } : {} })
|
|
2692
|
+
});
|
|
2693
|
+
const resp = await handle(innerReq);
|
|
2694
|
+
let parsed = {};
|
|
2695
|
+
try {
|
|
2696
|
+
const t = await resp.text();
|
|
2697
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2698
|
+
} catch {
|
|
2699
|
+
parsed = {};
|
|
2700
|
+
}
|
|
2701
|
+
if (!resp.ok) {
|
|
2702
|
+
return { status: resp.status, body: { success: false, error: { code: "saml_register_failed", message: parsed?.message || "SAML provider registration failed" } } };
|
|
2703
|
+
}
|
|
2704
|
+
return { status: 200, body: { success: true, data: { providerId: parsed?.providerId ?? providerId }, acsUrl, spMetadataUrl } };
|
|
2705
|
+
}
|
|
2706
|
+
var SSO_DOMAIN_TOKEN_PREFIX = "better-auth-token";
|
|
2707
|
+
function bareHostname(domain) {
|
|
2708
|
+
let d = domain.trim();
|
|
2709
|
+
if (!d) return d;
|
|
2710
|
+
const schemeIdx = d.indexOf("://");
|
|
2711
|
+
if (schemeIdx !== -1) {
|
|
2712
|
+
try {
|
|
2713
|
+
return new URL(d).hostname;
|
|
2714
|
+
} catch {
|
|
2715
|
+
d = d.slice(schemeIdx + 3);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
for (const sep of ["/", ":", "?", "#"]) {
|
|
2719
|
+
const i = d.indexOf(sep);
|
|
2720
|
+
if (i !== -1) d = d.slice(0, i);
|
|
2721
|
+
}
|
|
2722
|
+
return d;
|
|
2723
|
+
}
|
|
2724
|
+
function rewriteSsoAdminUrl(request, fromSuffix, toPath) {
|
|
2725
|
+
try {
|
|
2726
|
+
const url = new URL(request.url);
|
|
2727
|
+
return { origin: url.origin, innerUrl: `${url.origin}${url.pathname.replace(fromSuffix, toPath)}` };
|
|
2728
|
+
} catch {
|
|
2729
|
+
return null;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
function forwardAuthHeaders(request, origin) {
|
|
2733
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
2734
|
+
const cookie = request.headers.get("cookie");
|
|
2735
|
+
if (cookie) headers.set("cookie", cookie);
|
|
2736
|
+
const authz = request.headers.get("authorization");
|
|
2737
|
+
if (authz) headers.set("authorization", authz);
|
|
2738
|
+
headers.set("origin", request.headers.get("origin") || origin);
|
|
2739
|
+
return headers;
|
|
2740
|
+
}
|
|
2741
|
+
async function runRequestDomainVerification(handle, request) {
|
|
2742
|
+
let body;
|
|
2743
|
+
try {
|
|
2744
|
+
body = await request.json();
|
|
2745
|
+
} catch {
|
|
2746
|
+
body = {};
|
|
2747
|
+
}
|
|
2748
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2749
|
+
const providerId = str(body?.providerId);
|
|
2750
|
+
const domain = bareHostname(str(body?.domain));
|
|
2751
|
+
if (!providerId) {
|
|
2752
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
|
|
2753
|
+
}
|
|
2754
|
+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, "/sso/request-domain-verification");
|
|
2755
|
+
if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2756
|
+
const headers = forwardAuthHeaders(request, rw.origin);
|
|
2757
|
+
const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
|
|
2758
|
+
let parsed = {};
|
|
2759
|
+
try {
|
|
2760
|
+
const t = await resp.text();
|
|
2761
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2762
|
+
} catch {
|
|
2763
|
+
parsed = {};
|
|
2764
|
+
}
|
|
2765
|
+
if (!resp.ok) {
|
|
2766
|
+
if (resp.status === 404 && !parsed?.code) {
|
|
2767
|
+
return { status: 400, body: { success: false, error: { code: "domain_verification_disabled", message: "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION)." } } };
|
|
2768
|
+
}
|
|
2769
|
+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || "request_domain_verification_failed", message: parsed?.message || "Failed to request domain verification" } } };
|
|
2770
|
+
}
|
|
2771
|
+
const token = str(parsed?.domainVerificationToken);
|
|
2772
|
+
const label = `_${SSO_DOMAIN_TOKEN_PREFIX}-${providerId}`;
|
|
2773
|
+
const dnsRecordName = domain ? `${label}.${domain}` : label;
|
|
2774
|
+
const dnsRecordValue = `${label}=${token}`;
|
|
2775
|
+
return {
|
|
2776
|
+
status: 200,
|
|
2777
|
+
body: {
|
|
2778
|
+
success: true,
|
|
2779
|
+
data: { providerId, domain, token, dnsRecordType: "TXT", dnsRecordName, dnsRecordValue }
|
|
2780
|
+
}
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
async function runVerifyDomain(handle, request) {
|
|
2784
|
+
let body;
|
|
2785
|
+
try {
|
|
2786
|
+
body = await request.json();
|
|
2787
|
+
} catch {
|
|
2788
|
+
body = {};
|
|
2789
|
+
}
|
|
2790
|
+
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
2791
|
+
const providerId = str(body?.providerId);
|
|
2792
|
+
if (!providerId) {
|
|
2793
|
+
return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Missing required field: providerId" } } };
|
|
2794
|
+
}
|
|
2795
|
+
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, "/sso/verify-domain");
|
|
2796
|
+
if (!rw) return { status: 400, body: { success: false, error: { code: "invalid_request", message: "Bad request URL" } } };
|
|
2797
|
+
const headers = forwardAuthHeaders(request, rw.origin);
|
|
2798
|
+
const resp = await handle(new Request(rw.innerUrl, { method: "POST", headers, body: JSON.stringify({ providerId }) }));
|
|
2799
|
+
let parsed = {};
|
|
2800
|
+
try {
|
|
2801
|
+
const t = await resp.text();
|
|
2802
|
+
parsed = t ? JSON.parse(t) : {};
|
|
2803
|
+
} catch {
|
|
2804
|
+
parsed = {};
|
|
2805
|
+
}
|
|
2806
|
+
if (resp.ok) {
|
|
2807
|
+
return { status: 200, body: { success: true, data: { providerId, verified: true, message: "Domain ownership verified \u2014 this provider can now sign users in." } } };
|
|
2808
|
+
}
|
|
2809
|
+
let message = parsed?.message || "Domain verification failed";
|
|
2810
|
+
if (resp.status === 404 && !parsed?.code) {
|
|
2811
|
+
message = "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).";
|
|
2812
|
+
} else if (parsed?.code === "NO_PENDING_VERIFICATION") {
|
|
2813
|
+
message = "No pending verification \u2014 click \u201CRequest Domain Verification\u201D first to get the DNS record.";
|
|
2814
|
+
} else if (parsed?.code === "DOMAIN_VERIFICATION_FAILED") {
|
|
2815
|
+
message = "DNS TXT record not found yet. Add the record shown when you requested verification, allow time for DNS to propagate, then retry.";
|
|
2816
|
+
}
|
|
2817
|
+
return { status: resp.status, body: { success: false, error: { code: parsed?.code || "verify_domain_failed", message } } };
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2264
2820
|
// src/manifest.ts
|
|
2265
2821
|
import {
|
|
2266
2822
|
SysAccount,
|
|
@@ -2399,7 +2955,33 @@ var AuthPlugin = class {
|
|
|
2399
2955
|
// source of truth.
|
|
2400
2956
|
dashboards: [SystemOverviewDashboard],
|
|
2401
2957
|
// ADR-0021 — datasets backing the System Overview dashboard's widgets.
|
|
2402
|
-
datasets: SystemOverviewDatasets
|
|
2958
|
+
datasets: SystemOverviewDatasets,
|
|
2959
|
+
// ADR-0024 / cloud#551 — surface "SSO Providers" (sys_sso_provider) in the
|
|
2960
|
+
// Setup app's Access Control group, but ONLY when the external-IdP RP is
|
|
2961
|
+
// wired (self-host `OS_SSO_ENABLED`, or the cloud per-env `planAllowsSso`
|
|
2962
|
+
// arriving via `plugins.sso`). Without the gate the entry would render an
|
|
2963
|
+
// empty list + a "Register" button whose endpoint 404s when SSO is off.
|
|
2964
|
+
// Owning-plugin-contributes pattern (ADR-0029 K2), mirroring plugin-security.
|
|
2965
|
+
...this.authManager.isSsoWired() ? {
|
|
2966
|
+
navigationContributions: [
|
|
2967
|
+
{
|
|
2968
|
+
app: "setup",
|
|
2969
|
+
group: "group_access_control",
|
|
2970
|
+
// After Roles/Permission-Sets (100) and Sharing (200), near API Keys (300).
|
|
2971
|
+
priority: 250,
|
|
2972
|
+
items: [
|
|
2973
|
+
{
|
|
2974
|
+
id: "nav_sso_providers",
|
|
2975
|
+
type: "object",
|
|
2976
|
+
label: "SSO Providers",
|
|
2977
|
+
objectName: "sys_sso_provider",
|
|
2978
|
+
icon: "log-in",
|
|
2979
|
+
requiredPermissions: ["manage_platform_settings"]
|
|
2980
|
+
}
|
|
2981
|
+
]
|
|
2982
|
+
}
|
|
2983
|
+
]
|
|
2984
|
+
} : {}
|
|
2403
2985
|
});
|
|
2404
2986
|
ctx.logger.info("Auth Plugin initialized successfully");
|
|
2405
2987
|
}
|
|
@@ -2624,6 +3206,24 @@ var AuthPlugin = class {
|
|
|
2624
3206
|
const n = Math.floor(Number(values.password_history_count));
|
|
2625
3207
|
if (Number.isFinite(n) && n >= 0) patch.passwordHistoryCount = Math.min(24, n);
|
|
2626
3208
|
}
|
|
3209
|
+
if (isExplicit("password_expiry_days")) {
|
|
3210
|
+
const n = Math.floor(Number(values.password_expiry_days));
|
|
3211
|
+
if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n);
|
|
3212
|
+
}
|
|
3213
|
+
if (isExplicit("mfa_required")) {
|
|
3214
|
+
const on = asBoolean(values.mfa_required, false);
|
|
3215
|
+
patch.mfaRequired = on;
|
|
3216
|
+
if (on) {
|
|
3217
|
+
patch.plugins = {
|
|
3218
|
+
...patch.plugins ?? {},
|
|
3219
|
+
twoFactor: true
|
|
3220
|
+
};
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
if (isExplicit("mfa_grace_period_days")) {
|
|
3224
|
+
const n = Math.floor(Number(values.mfa_grace_period_days));
|
|
3225
|
+
if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n);
|
|
3226
|
+
}
|
|
2627
3227
|
const session = {};
|
|
2628
3228
|
if (isExplicit("session_expiry_days")) {
|
|
2629
3229
|
const d = asPositiveInt(values.session_expiry_days);
|
|
@@ -2636,6 +3236,26 @@ var AuthPlugin = class {
|
|
|
2636
3236
|
if (Object.keys(session).length > 0) {
|
|
2637
3237
|
patch.session = session;
|
|
2638
3238
|
}
|
|
3239
|
+
const asNonNeg = (v) => {
|
|
3240
|
+
const n = Math.floor(Number(v));
|
|
3241
|
+
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
3242
|
+
};
|
|
3243
|
+
if (isExplicit("session_idle_timeout_minutes")) {
|
|
3244
|
+
const n = asNonNeg(values.session_idle_timeout_minutes);
|
|
3245
|
+
if (n !== void 0) patch.sessionIdleTimeoutMinutes = n;
|
|
3246
|
+
}
|
|
3247
|
+
if (isExplicit("session_absolute_max_hours")) {
|
|
3248
|
+
const n = asNonNeg(values.session_absolute_max_hours);
|
|
3249
|
+
if (n !== void 0) patch.sessionAbsoluteMaxHours = n;
|
|
3250
|
+
}
|
|
3251
|
+
if (isExplicit("max_concurrent_sessions_per_user")) {
|
|
3252
|
+
const n = asNonNeg(values.max_concurrent_sessions_per_user);
|
|
3253
|
+
if (n !== void 0) patch.maxConcurrentSessions = n;
|
|
3254
|
+
}
|
|
3255
|
+
if (isExplicit("allowed_ip_ranges")) {
|
|
3256
|
+
const raw = asTrimmedString(values.allowed_ip_ranges) ?? "";
|
|
3257
|
+
patch.allowedIpRanges = raw.split(/[\n,]+/).map((r) => r.trim()).filter(Boolean);
|
|
3258
|
+
}
|
|
2639
3259
|
const asNonNegativeInt = (value) => {
|
|
2640
3260
|
const n = Math.floor(Number(value));
|
|
2641
3261
|
return Number.isFinite(n) && n >= 0 ? n : void 0;
|
|
@@ -2784,6 +3404,21 @@ var AuthPlugin = class {
|
|
|
2784
3404
|
);
|
|
2785
3405
|
}
|
|
2786
3406
|
const rawApp = httpServer.getRawApp();
|
|
3407
|
+
if (typeof rawApp.use === "function") rawApp.use(`${basePath}/*`, async (c, next) => {
|
|
3408
|
+
const mgr = this.authManager;
|
|
3409
|
+
if (!mgr || typeof mgr.isClientIpAllowed !== "function") return next();
|
|
3410
|
+
const path = c.req.path || "";
|
|
3411
|
+
if (path.endsWith("/config") || path.endsWith("/bootstrap-status")) return next();
|
|
3412
|
+
const fwd = c.req.header("x-forwarded-for");
|
|
3413
|
+
const ip = typeof fwd === "string" && fwd.split(",")[0].trim() || c.req.header("cf-connecting-ip") || c.req.header("x-real-ip") || void 0;
|
|
3414
|
+
if (!mgr.isClientIpAllowed(ip)) {
|
|
3415
|
+
return c.json(
|
|
3416
|
+
{ success: false, error: { code: "IP_NOT_ALLOWED", message: "Sign-in is not allowed from your network." } },
|
|
3417
|
+
403
|
|
3418
|
+
);
|
|
3419
|
+
}
|
|
3420
|
+
return next();
|
|
3421
|
+
});
|
|
2787
3422
|
rawApp.get(`${basePath}/config`, async (c) => {
|
|
2788
3423
|
try {
|
|
2789
3424
|
const config = this.authManager.getPublicConfig();
|
|
@@ -2875,6 +3510,19 @@ var AuthPlugin = class {
|
|
|
2875
3510
|
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
2876
3511
|
}
|
|
2877
3512
|
});
|
|
3513
|
+
rawApp.post(`${basePath}/admin/sso/register`, async (c) => {
|
|
3514
|
+
try {
|
|
3515
|
+
const { status, body } = await runRegisterSsoProviderFromForm(
|
|
3516
|
+
(req) => this.authManager.handleRequest(req),
|
|
3517
|
+
c.req.raw
|
|
3518
|
+
);
|
|
3519
|
+
return c.json(body, status);
|
|
3520
|
+
} catch (error) {
|
|
3521
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3522
|
+
ctx.logger.error("[AuthPlugin] sso/register bridge failed", err);
|
|
3523
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3524
|
+
}
|
|
3525
|
+
});
|
|
2878
3526
|
rawApp.post(`${basePath}/admin/unlock-user`, async (c) => {
|
|
2879
3527
|
try {
|
|
2880
3528
|
let body = {};
|
|
@@ -2908,6 +3556,45 @@ var AuthPlugin = class {
|
|
|
2908
3556
|
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
2909
3557
|
}
|
|
2910
3558
|
});
|
|
3559
|
+
rawApp.post(`${basePath}/admin/sso/register-saml`, async (c) => {
|
|
3560
|
+
try {
|
|
3561
|
+
const { status, body } = await runRegisterSamlProviderFromForm(
|
|
3562
|
+
(req) => this.authManager.handleRequest(req),
|
|
3563
|
+
c.req.raw
|
|
3564
|
+
);
|
|
3565
|
+
return c.json(body, status);
|
|
3566
|
+
} catch (error) {
|
|
3567
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3568
|
+
ctx.logger.error("[AuthPlugin] sso/register-saml bridge failed", err);
|
|
3569
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3570
|
+
}
|
|
3571
|
+
});
|
|
3572
|
+
rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c) => {
|
|
3573
|
+
try {
|
|
3574
|
+
const { status, body } = await runRequestDomainVerification(
|
|
3575
|
+
(req) => this.authManager.handleRequest(req),
|
|
3576
|
+
c.req.raw
|
|
3577
|
+
);
|
|
3578
|
+
return c.json(body, status);
|
|
3579
|
+
} catch (error) {
|
|
3580
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3581
|
+
ctx.logger.error("[AuthPlugin] sso/request-domain-verification bridge failed", err);
|
|
3582
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3583
|
+
}
|
|
3584
|
+
});
|
|
3585
|
+
rawApp.post(`${basePath}/admin/sso/verify-domain`, async (c) => {
|
|
3586
|
+
try {
|
|
3587
|
+
const { status, body } = await runVerifyDomain(
|
|
3588
|
+
(req) => this.authManager.handleRequest(req),
|
|
3589
|
+
c.req.raw
|
|
3590
|
+
);
|
|
3591
|
+
return c.json(body, status);
|
|
3592
|
+
} catch (error) {
|
|
3593
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3594
|
+
ctx.logger.error("[AuthPlugin] sso/verify-domain bridge failed", err);
|
|
3595
|
+
return c.json({ success: false, error: { code: "internal", message: err.message } }, 500);
|
|
3596
|
+
}
|
|
3597
|
+
});
|
|
2911
3598
|
rawApp.post(`${basePath}/sys-oauth-application/register`, async (c) => {
|
|
2912
3599
|
try {
|
|
2913
3600
|
let body = {};
|
|
@@ -3073,8 +3760,13 @@ export {
|
|
|
3073
3760
|
buildTwoFactorPluginSchema,
|
|
3074
3761
|
createObjectQLAdapter,
|
|
3075
3762
|
createObjectQLAdapterFactory,
|
|
3763
|
+
ipMatchesRange,
|
|
3076
3764
|
resolveProtocolName,
|
|
3765
|
+
runRegisterSamlProviderFromForm,
|
|
3766
|
+
runRegisterSsoProviderFromForm,
|
|
3767
|
+
runRequestDomainVerification,
|
|
3077
3768
|
runSetInitialPassword,
|
|
3769
|
+
runVerifyDomain,
|
|
3078
3770
|
withSystemReadContext
|
|
3079
3771
|
};
|
|
3080
3772
|
//# sourceMappingURL=index.mjs.map
|