@objectstack/plugin-auth 8.0.1 → 9.0.1

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/README.md CHANGED
@@ -24,7 +24,7 @@ Authentication & Identity Plugin for ObjectStack.
24
24
  - ✅ **Session Management** - Automatic session handling
25
25
  - ✅ **Password Reset** - Email-based password reset flow
26
26
  - ✅ **Email Verification** - Email verification workflow
27
- - **2FA** - Two-factor authentication (when enabled)
27
+ - ⚠️ **2FA backend wiring** - better-auth two-factor plugin can be enabled for custom UIs
28
28
  - ✅ **Passkeys** - WebAuthn/Passkey support (when enabled)
29
29
  - ✅ **Magic Links** - Passwordless authentication (when enabled)
30
30
  - ✅ **Organizations** - Multi-tenant support (when enabled)
@@ -91,7 +91,7 @@ new AuthPlugin({
91
91
  baseUrl: 'http://localhost:3000',
92
92
  plugins: {
93
93
  organization: true, // Enable organization/teams
94
- twoFactor: true, // Enable 2FA
94
+ twoFactor: true, // Enable backend 2FA endpoints for a custom UI
95
95
  passkeys: true, // Enable passkey support
96
96
  }
97
97
  })
@@ -105,7 +105,7 @@ The plugin accepts configuration via `AuthConfig` schema from `@objectstack/spec
105
105
  - `baseUrl` - Base URL for auth routes
106
106
  - `databaseUrl` - Database connection string
107
107
  - `providers` - Array of OAuth provider configurations
108
- - `plugins` - Enable additional auth features (organization, 2FA, passkeys, magic link)
108
+ - `plugins` - Enable additional auth features (organization, backend 2FA, passkeys, magic link)
109
109
  - `session` - Session configuration (expiry, update frequency)
110
110
 
111
111
  ## API Routes
@@ -132,9 +132,13 @@ The plugin forwards all requests under `/api/v1/auth/*` directly to better-auth'
132
132
  - `GET /api/v1/auth/authorize/[provider]` - Start OAuth flow
133
133
  - `GET /api/v1/auth/callback/[provider]` - OAuth callback
134
134
 
135
- ### 2FA (when enabled)
136
- - `POST /api/v1/auth/two-factor/enable` - Enable 2FA
137
- - `POST /api/v1/auth/two-factor/verify` - Verify 2FA code
135
+ ### 2FA backend (when enabled)
136
+ - `POST /api/v1/auth/two-factor/enable` - Start 2FA enrollment
137
+ - `POST /api/v1/auth/two-factor/verify-totp` - Verify a TOTP code
138
+
139
+ The open-source Console does not yet provide a complete TOTP enrollment,
140
+ login challenge, and backup-code recovery flow. Keep `plugins.twoFactor`
141
+ disabled unless your application UI handles that full flow.
138
142
 
139
143
  ### Passkeys (when enabled)
140
144
  - `POST /api/v1/auth/passkey/register` - Register a passkey
@@ -158,7 +162,7 @@ This package provides authentication services powered by better-auth. Current im
158
162
  6. ✅ Direct request forwarding to better-auth handler
159
163
  7. ✅ Full better-auth API support
160
164
  8. ✅ OAuth providers (configurable)
161
- 9. 2FA, passkeys, magic links (configurable)
165
+ 9. ⚠️ Backend 2FA wiring, passkeys, magic links (configurable; custom UI required for 2FA)
162
166
  10. ✅ ObjectQL-based database implementation (no ORM required)
163
167
 
164
168
  ### Architecture
package/dist/index.d.mts CHANGED
@@ -85,9 +85,25 @@ declare class AuthPlugin implements Plugin {
85
85
  dependencies: string[];
86
86
  private options;
87
87
  private authManager;
88
+ private configuredSocialProviders;
88
89
  constructor(options?: AuthPluginOptions);
90
+ /**
91
+ * Open-source provider fallback: enable Google sign-in from conventional
92
+ * provider env vars when the application did not configure Google itself.
93
+ * Enterprise / product packages can contribute richer provider sets through
94
+ * the `auth:configure` hook below.
95
+ */
96
+ private applyEnvSocialProviderFallbacks;
89
97
  init(ctx: PluginContext): Promise<void>;
90
98
  start(ctx: PluginContext): Promise<void>;
99
+ /**
100
+ * Bind the small open-source auth settings namespace to better-auth config.
101
+ *
102
+ * Only explicit settings values (stored or OS_AUTH_* env overrides) affect
103
+ * runtime config. Manifest defaults are UI defaults and do not mask code or
104
+ * deployment configuration.
105
+ */
106
+ private bindAuthSettings;
91
107
  destroy(): Promise<void>;
92
108
  /**
93
109
  * Dev-only admin bootstrap.
@@ -307,6 +323,15 @@ declare class AuthManager {
307
323
  * a warning is emitted.
308
324
  */
309
325
  setRuntimeBaseUrl(url: string): void;
326
+ /**
327
+ * Merge runtime configuration into the manager.
328
+ *
329
+ * Settings-backed auth policy can change after the manager is constructed.
330
+ * better-auth itself is created lazily, so changing config before the first
331
+ * request is enough. If an instance already exists, reset it so the next
332
+ * request rebuilds with the new policy.
333
+ */
334
+ applyConfigPatch(patch: Partial<AuthManagerOptions>): void;
310
335
  /**
311
336
  * Inject (or replace) the outbound email service used by better-auth
312
337
  * callbacks. Safe to call after construction but BEFORE the first
@@ -393,6 +418,7 @@ declare class AuthManager {
393
418
  multiOrgEnabled: boolean;
394
419
  oidcProvider: boolean;
395
420
  deviceAuthorization: boolean;
421
+ admin: boolean;
396
422
  };
397
423
  };
398
424
  /**
package/dist/index.d.ts CHANGED
@@ -85,9 +85,25 @@ declare class AuthPlugin implements Plugin {
85
85
  dependencies: string[];
86
86
  private options;
87
87
  private authManager;
88
+ private configuredSocialProviders;
88
89
  constructor(options?: AuthPluginOptions);
90
+ /**
91
+ * Open-source provider fallback: enable Google sign-in from conventional
92
+ * provider env vars when the application did not configure Google itself.
93
+ * Enterprise / product packages can contribute richer provider sets through
94
+ * the `auth:configure` hook below.
95
+ */
96
+ private applyEnvSocialProviderFallbacks;
89
97
  init(ctx: PluginContext): Promise<void>;
90
98
  start(ctx: PluginContext): Promise<void>;
99
+ /**
100
+ * Bind the small open-source auth settings namespace to better-auth config.
101
+ *
102
+ * Only explicit settings values (stored or OS_AUTH_* env overrides) affect
103
+ * runtime config. Manifest defaults are UI defaults and do not mask code or
104
+ * deployment configuration.
105
+ */
106
+ private bindAuthSettings;
91
107
  destroy(): Promise<void>;
92
108
  /**
93
109
  * Dev-only admin bootstrap.
@@ -307,6 +323,15 @@ declare class AuthManager {
307
323
  * a warning is emitted.
308
324
  */
309
325
  setRuntimeBaseUrl(url: string): void;
326
+ /**
327
+ * Merge runtime configuration into the manager.
328
+ *
329
+ * Settings-backed auth policy can change after the manager is constructed.
330
+ * better-auth itself is created lazily, so changing config before the first
331
+ * request is enough. If an instance already exists, reset it so the next
332
+ * request rebuilds with the new policy.
333
+ */
334
+ applyConfigPatch(patch: Partial<AuthManagerOptions>): void;
310
335
  /**
311
336
  * Inject (or replace) the outbound email service used by better-auth
312
337
  * callbacks. Safe to call after construction but BEFORE the first
@@ -393,6 +418,7 @@ declare class AuthManager {
393
418
  multiOrgEnabled: boolean;
394
419
  oidcProvider: boolean;
395
420
  deviceAuthorization: boolean;
421
+ admin: boolean;
396
422
  };
397
423
  };
398
424
  /**
package/dist/index.js CHANGED
@@ -575,6 +575,18 @@ function installWebContainerRequestStatePolyfill() {
575
575
  );
576
576
  }
577
577
  }
578
+ function readBooleanEnv(name, legacyName) {
579
+ const env = globalThis?.process?.env;
580
+ const raw = env?.[name] ?? (legacyName ? env?.[legacyName] : void 0);
581
+ if (raw == null) return void 0;
582
+ const normalized = String(raw).trim().toLowerCase();
583
+ return !["0", "false", "off", "no"].includes(normalized);
584
+ }
585
+ function readDisableSignUpEnv() {
586
+ const signupEnabled = readBooleanEnv("OS_AUTH_SIGNUP_ENABLED");
587
+ if (signupEnabled != null) return !signupEnabled;
588
+ return readBooleanEnv("OS_DISABLE_SIGNUP");
589
+ }
578
590
  var AuthManager = class {
579
591
  constructor(config) {
580
592
  this.auth = null;
@@ -658,13 +670,10 @@ var AuthManager = class {
658
670
  // Social / OAuth providers
659
671
  ...this.config.socialProviders ? { socialProviders: this.config.socialProviders } : {},
660
672
  // Email and password configuration.
661
- // `disableSignUp`: the env var `OS_DISABLE_SIGNUP=true` overrides
662
- // the config-file value so deployments can flip the toggle without
663
- // a code change (`getPublicConfig()` applies the same precedence so
664
- // `/auth/config` stays consistent with the server enforcement).
673
+ // `disableSignUp`: env overrides config/settings so deployments can
674
+ // lock the registration policy without relying on UI state.
665
675
  emailAndPassword: (() => {
666
- const disableSignUpEnv = globalThis?.process?.env?.OS_DISABLE_SIGNUP;
667
- const disableSignUpFromEnv = disableSignUpEnv != null ? String(disableSignUpEnv).toLowerCase() === "true" : void 0;
676
+ const disableSignUpFromEnv = readDisableSignUpEnv();
668
677
  const effectiveDisableSignUp = disableSignUpFromEnv ?? this.config.emailAndPassword?.disableSignUp;
669
678
  return {
670
679
  enabled: this.config.emailAndPassword?.enabled ?? true,
@@ -879,9 +888,10 @@ var AuthManager = class {
879
888
  const plugins = [];
880
889
  const oidcEnv = globalThis?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
881
890
  const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === "true" : void 0;
891
+ const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
882
892
  const enabled = {
883
893
  organization: pluginConfig.organization ?? true,
884
- twoFactor: pluginConfig.twoFactor ?? false,
894
+ twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false,
885
895
  passkeys: pluginConfig.passkeys ?? false,
886
896
  magicLink: pluginConfig.magicLink ?? false,
887
897
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
@@ -1152,16 +1162,19 @@ var AuthManager = class {
1152
1162
  });
1153
1163
  return (Array.isArray(members) ? members : []).some((m) => {
1154
1164
  const raw = typeof m?.role === "string" ? m.role : "";
1155
- const roles = raw.split(",").map((s) => s.trim().toLowerCase());
1156
- return roles.includes("owner") || roles.includes("admin");
1165
+ const roles2 = raw.split(",").map((s) => s.trim().toLowerCase());
1166
+ return roles2.includes("owner") || roles2.includes("admin");
1157
1167
  });
1158
1168
  } catch {
1159
1169
  return false;
1160
1170
  }
1161
1171
  };
1162
1172
  const promote = await isPlatformAdmin() || await isActiveOrgAdmin();
1163
- if (!promote) return { user, session };
1164
- return { user: { ...user, role: "admin" }, session };
1173
+ const storedRole = typeof user.role === "string" ? user.role : "";
1174
+ const roles = storedRole.split(",").map((s) => s.trim()).filter(Boolean);
1175
+ if (promote && !roles.includes("admin")) roles.push("admin");
1176
+ if (!promote) return { user: { ...user, roles }, session };
1177
+ return { user: { ...user, role: "admin", roles }, session };
1165
1178
  }));
1166
1179
  }
1167
1180
  return plugins;
@@ -1223,6 +1236,39 @@ var AuthManager = class {
1223
1236
  }
1224
1237
  this.config = { ...this.config, baseUrl: url };
1225
1238
  }
1239
+ /**
1240
+ * Merge runtime configuration into the manager.
1241
+ *
1242
+ * Settings-backed auth policy can change after the manager is constructed.
1243
+ * better-auth itself is created lazily, so changing config before the first
1244
+ * request is enough. If an instance already exists, reset it so the next
1245
+ * request rebuilds with the new policy.
1246
+ */
1247
+ applyConfigPatch(patch) {
1248
+ const next = {
1249
+ ...this.config,
1250
+ ...patch,
1251
+ ...patch.emailAndPassword ? {
1252
+ emailAndPassword: {
1253
+ ...this.config.emailAndPassword ?? {},
1254
+ ...patch.emailAndPassword
1255
+ }
1256
+ } : {},
1257
+ ...patch.plugins ? {
1258
+ plugins: {
1259
+ ...this.config.plugins ?? {},
1260
+ ...patch.plugins
1261
+ }
1262
+ } : {}
1263
+ };
1264
+ if ("socialProviders" in patch) {
1265
+ next.socialProviders = patch.socialProviders;
1266
+ }
1267
+ this.config = next;
1268
+ if (this.auth && !patch.authInstance) {
1269
+ this.auth = null;
1270
+ }
1271
+ }
1226
1272
  /**
1227
1273
  * Inject (or replace) the outbound email service used by better-auth
1228
1274
  * callbacks. Safe to call after construction but BEFORE the first
@@ -1355,8 +1401,7 @@ var AuthManager = class {
1355
1401
  }
1356
1402
  }
1357
1403
  const emailPasswordConfig = this.config.emailAndPassword ?? {};
1358
- const disableSignUpEnv = globalThis?.process?.env?.OS_DISABLE_SIGNUP;
1359
- const disableSignUpFromEnv = disableSignUpEnv != null ? String(disableSignUpEnv).toLowerCase() === "true" : void 0;
1404
+ const disableSignUpFromEnv = readDisableSignUpEnv();
1360
1405
  const emailPassword = {
1361
1406
  enabled: emailPasswordConfig.enabled !== false,
1362
1407
  // Default to true
@@ -1381,14 +1426,16 @@ var AuthManager = class {
1381
1426
  const privacyUrl = resolveLegalUrl(rawPrivacyUrl, DEFAULT_PRIVACY_URL);
1382
1427
  const oidcEnv = globalThis?.process?.env?.OS_OIDC_PROVIDER_ENABLED;
1383
1428
  const oidcFromEnv = oidcEnv != null ? String(oidcEnv).toLowerCase() === "true" : void 0;
1429
+ const twoFactorFromEnv = readBooleanEnv("OS_AUTH_TWO_FACTOR");
1384
1430
  const features = {
1385
- twoFactor: pluginConfig.twoFactor ?? false,
1431
+ twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false,
1386
1432
  passkeys: pluginConfig.passkeys ?? false,
1387
1433
  magicLink: pluginConfig.magicLink ?? false,
1388
1434
  organization: pluginConfig.organization ?? true,
1389
1435
  multiOrgEnabled,
1390
1436
  oidcProvider: oidcFromEnv ?? pluginConfig.oidcProvider ?? false,
1391
1437
  deviceAuthorization: pluginConfig.deviceAuthorization ?? false,
1438
+ admin: pluginConfig.admin ?? false,
1392
1439
  ...termsUrl ? { termsUrl } : {},
1393
1440
  ...privacyUrl ? { privacyUrl } : {}
1394
1441
  };
@@ -1488,6 +1535,30 @@ var AuthPlugin = class {
1488
1535
  ...options
1489
1536
  };
1490
1537
  }
1538
+ /**
1539
+ * Open-source provider fallback: enable Google sign-in from conventional
1540
+ * provider env vars when the application did not configure Google itself.
1541
+ * Enterprise / product packages can contribute richer provider sets through
1542
+ * the `auth:configure` hook below.
1543
+ */
1544
+ applyEnvSocialProviderFallbacks(config) {
1545
+ const env = globalThis?.process?.env;
1546
+ if (String(env?.OS_AUTH_GOOGLE_ENABLED ?? "true").toLowerCase() === "false") return;
1547
+ const googleClientId = env?.GOOGLE_CLIENT_ID;
1548
+ const googleClientSecret = env?.GOOGLE_CLIENT_SECRET;
1549
+ if (!googleClientId || !googleClientSecret) return;
1550
+ const socialProviders = {
1551
+ ...config.socialProviders ?? {}
1552
+ };
1553
+ if (!socialProviders.google) {
1554
+ socialProviders.google = {
1555
+ clientId: googleClientId,
1556
+ clientSecret: googleClientSecret,
1557
+ enabled: true
1558
+ };
1559
+ config.socialProviders = socialProviders;
1560
+ }
1561
+ }
1491
1562
  async init(ctx) {
1492
1563
  ctx.logger.info("Initializing Auth Plugin...");
1493
1564
  if (!this.options.secret) {
@@ -1497,10 +1568,14 @@ var AuthPlugin = class {
1497
1568
  if (!dataEngine) {
1498
1569
  ctx.logger.warn("No data engine service found - auth will use in-memory storage");
1499
1570
  }
1500
- this.authManager = new AuthManager({
1571
+ const authConfig = {
1501
1572
  ...this.options,
1502
1573
  dataEngine
1503
- });
1574
+ };
1575
+ this.applyEnvSocialProviderFallbacks(authConfig);
1576
+ await ctx.trigger("auth:configure", authConfig, ctx);
1577
+ this.configuredSocialProviders = authConfig.socialProviders ? { ...authConfig.socialProviders } : void 0;
1578
+ this.authManager = new AuthManager(authConfig);
1504
1579
  ctx.registerService("auth", this.authManager);
1505
1580
  ctx.getService("manifest").register({
1506
1581
  ...authPluginManifestHeader,
@@ -1529,7 +1604,9 @@ var AuthPlugin = class {
1529
1604
  // (e.g. legacy `users.view` had phone/status/active columns that do
1530
1605
  // not exist on sys_user). Schema-embedded listViews is the single
1531
1606
  // source of truth.
1532
- dashboards: [import_apps.SystemOverviewDashboard]
1607
+ dashboards: [import_apps.SystemOverviewDashboard],
1608
+ // ADR-0021 — datasets backing the System Overview dashboard's widgets.
1609
+ datasets: import_apps.SystemOverviewDatasets
1533
1610
  });
1534
1611
  ctx.logger.info("Auth Plugin initialized successfully");
1535
1612
  }
@@ -1541,6 +1618,7 @@ var AuthPlugin = class {
1541
1618
  if (this.options.registerRoutes) {
1542
1619
  ctx.hook("kernel:ready", async () => {
1543
1620
  if (this.authManager) {
1621
+ await this.bindAuthSettings(ctx);
1544
1622
  try {
1545
1623
  const emailSvc = ctx.getService("email");
1546
1624
  if (emailSvc) {
@@ -1627,6 +1705,97 @@ var AuthPlugin = class {
1627
1705
  }
1628
1706
  ctx.logger.info("Auth Plugin started successfully");
1629
1707
  }
1708
+ /**
1709
+ * Bind the small open-source auth settings namespace to better-auth config.
1710
+ *
1711
+ * Only explicit settings values (stored or OS_AUTH_* env overrides) affect
1712
+ * runtime config. Manifest defaults are UI defaults and do not mask code or
1713
+ * deployment configuration.
1714
+ */
1715
+ async bindAuthSettings(ctx) {
1716
+ if (!this.authManager) return;
1717
+ let settings;
1718
+ try {
1719
+ settings = ctx.getService("settings");
1720
+ } catch {
1721
+ return;
1722
+ }
1723
+ if (!settings || typeof settings.getNamespace !== "function") return;
1724
+ const applySettings = async () => {
1725
+ if (!this.authManager) return;
1726
+ try {
1727
+ const payload = await settings.getNamespace("auth");
1728
+ const values = {};
1729
+ const sources = {};
1730
+ for (const [key, entry] of Object.entries(payload.values)) {
1731
+ values[key] = entry?.value;
1732
+ sources[key] = entry?.source;
1733
+ }
1734
+ const isExplicit = (key) => (sources[key] ?? "default") !== "default";
1735
+ const asBoolean = (value, fallback) => {
1736
+ if (typeof value === "boolean") return value;
1737
+ if (typeof value === "string") return value.toLowerCase() !== "false";
1738
+ if (typeof value === "number") return value !== 0;
1739
+ return fallback;
1740
+ };
1741
+ const asTrimmedString = (value) => {
1742
+ if (typeof value !== "string") return void 0;
1743
+ const trimmed = value.trim();
1744
+ return trimmed ? trimmed : void 0;
1745
+ };
1746
+ const patch = {};
1747
+ const emailAndPassword = {};
1748
+ if (isExplicit("email_password_enabled")) {
1749
+ emailAndPassword.enabled = asBoolean(values.email_password_enabled, true);
1750
+ }
1751
+ if (isExplicit("signup_enabled")) {
1752
+ emailAndPassword.disableSignUp = !asBoolean(values.signup_enabled, true);
1753
+ }
1754
+ if (isExplicit("require_email_verification")) {
1755
+ emailAndPassword.requireEmailVerification = asBoolean(
1756
+ values.require_email_verification,
1757
+ false
1758
+ );
1759
+ }
1760
+ if (Object.keys(emailAndPassword).length > 0) {
1761
+ patch.emailAndPassword = emailAndPassword;
1762
+ }
1763
+ if (isExplicit("google_enabled") || isExplicit("google_client_id") || isExplicit("google_client_secret")) {
1764
+ const socialProviders = {
1765
+ ...this.configuredSocialProviders ?? {}
1766
+ };
1767
+ const env = globalThis?.process?.env;
1768
+ const googleEnabledFromEnv = env?.OS_AUTH_GOOGLE_ENABLED != null ? asBoolean(env.OS_AUTH_GOOGLE_ENABLED, true) : void 0;
1769
+ const googleClientId = asTrimmedString(values.google_client_id) ?? env?.GOOGLE_CLIENT_ID;
1770
+ const googleClientSecret = asTrimmedString(values.google_client_secret) ?? env?.GOOGLE_CLIENT_SECRET;
1771
+ if (googleEnabledFromEnv ?? (isExplicit("google_enabled") ? asBoolean(values.google_enabled, true) : true)) {
1772
+ if (!socialProviders.google && googleClientId && googleClientSecret) {
1773
+ socialProviders.google = {
1774
+ clientId: googleClientId,
1775
+ clientSecret: googleClientSecret,
1776
+ enabled: true
1777
+ };
1778
+ }
1779
+ } else {
1780
+ delete socialProviders.google;
1781
+ }
1782
+ patch.socialProviders = Object.keys(socialProviders).length > 0 ? socialProviders : void 0;
1783
+ }
1784
+ if (Object.keys(patch).length > 0) {
1785
+ this.authManager.applyConfigPatch(patch);
1786
+ }
1787
+ } catch (err) {
1788
+ ctx.logger.warn("Auth: failed to apply auth settings: " + (err?.message ?? err));
1789
+ }
1790
+ };
1791
+ await applySettings();
1792
+ if (typeof settings.subscribe === "function") {
1793
+ settings.subscribe("auth", () => {
1794
+ void applySettings();
1795
+ });
1796
+ ctx.logger.info("Auth: bound to settings namespace=auth");
1797
+ }
1798
+ }
1630
1799
  async destroy() {
1631
1800
  this.authManager = null;
1632
1801
  }