@objectstack/plugin-auth 10.3.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -169,6 +169,34 @@ interface AuthManagerOptions extends Partial<AuthConfig> {
169
169
  * Required for database operations using ObjectQL instead of third-party ORMs
170
170
  */
171
171
  dataEngine?: IDataEngine;
172
+ /**
173
+ * Optional callback invoked AFTER an organization is created via better-auth's
174
+ * `createOrganization` (the org-plugin `afterCreateOrganization` hook). Lets a
175
+ * host stack run org-creation side effects that core `databaseHooks` can't —
176
+ * better-auth's org-plugin models (`organization`/`member`) do NOT fire those.
177
+ * The cloud control plane uses it to provision an org's born-with production
178
+ * environment. Failure-isolated: org creation is never rolled back.
179
+ */
180
+ onOrganizationCreated?: (data: {
181
+ organizationId: string;
182
+ userId?: string;
183
+ name?: string;
184
+ slug?: string;
185
+ }) => void | Promise<void>;
186
+ /**
187
+ * D5.1 — OIDC OP authorization gate (cloud-as-IdP app-assignment).
188
+ * When set, it is called for an AUTHENTICATED subject on
189
+ * `/oauth2/authorize` before an authorization code is issued, with the
190
+ * subject + the requesting `clientId`. Return `false` to DENY (no code).
191
+ * The cloud control plane uses it to require org-membership: a cloud user
192
+ * may only obtain a code for an env client (`project_<envId>`) of an org
193
+ * they belong to. Unset (open editions / self-host, where the OP is not a
194
+ * multi-tenant issuer) = allow. Host is expected to fail CLOSED on error.
195
+ */
196
+ oidcAuthorizeGate?: (params: {
197
+ userId: string;
198
+ clientId: string;
199
+ }) => boolean | Promise<boolean>;
172
200
  /**
173
201
  * Base path for auth routes
174
202
  * Forwarded to better-auth's basePath option so it can match incoming
@@ -231,6 +259,40 @@ interface AuthManagerOptions extends Partial<AuthConfig> {
231
259
  * "create organization" screen.
232
260
  */
233
261
  databaseHooks?: BetterAuthOptions['databaseHooks'];
262
+ /**
263
+ * ADR-0069 D2 — account lockout (anti-brute-force). After this many
264
+ * consecutive failed sign-ins the account is locked for
265
+ * {@link lockoutDurationMinutes}. `0` (default) disables lockout.
266
+ * Enforced per-identity in the `/sign-in/email` before/after hooks
267
+ * (survives IP rotation, unlike the per-IP {@link rateLimit}).
268
+ */
269
+ lockoutThreshold?: number;
270
+ /** Minutes an account stays locked once the threshold is crossed. Default 15. */
271
+ lockoutDurationMinutes?: number;
272
+ /**
273
+ * ADR-0069 D1 — password complexity. When `passwordRequireComplexity` is on,
274
+ * a new password must contain at least `passwordMinClasses` (1-4) of the
275
+ * character classes upper / lower / digit / symbol. Enforced by a validator
276
+ * in the `/sign-up/email`, `/reset-password`, `/change-password` before hook
277
+ * (better-auth only enforces min/max length natively).
278
+ */
279
+ passwordRequireComplexity?: boolean;
280
+ /** Minimum distinct character classes required (1-4). Default 3. */
281
+ passwordMinClasses?: number;
282
+ /**
283
+ * ADR-0069 D1 — password history depth. When > 0, a password change/reset is
284
+ * rejected if the new password matches the current or any of the last
285
+ * `passwordHistoryCount` hashes (`sys_account.previous_password_hashes`).
286
+ * Reuses better-auth's native hash/verify — no bespoke crypto.
287
+ */
288
+ passwordHistoryCount?: number;
289
+ /**
290
+ * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
291
+ * better-auth's core `rateLimit`. The settings bind tightens `customRules`
292
+ * for the auth endpoints (`/sign-in/email`, `/sign-up/email`,
293
+ * `/reset-password`). Multi-node deployments need a shared `storage`.
294
+ */
295
+ rateLimit?: BetterAuthOptions['rateLimit'];
234
296
  }
235
297
  /**
236
298
  * Authentication Manager
@@ -391,6 +453,16 @@ declare class AuthManager {
391
453
  * sign in with email/password going forward.
392
454
  */
393
455
  getAuthContext(): Promise<any>;
456
+ /**
457
+ * SSO-only ("enforced") login mode: the login UI hides the local password
458
+ * form + self-registration so the team signs in via the IdP only.
459
+ * `OS_AUTH_SSO_ONLY` (when set) wins over the `ssoOnlyMode` config knob —
460
+ * parity with the `disableSignUp` env override — so a deployment can force
461
+ * it regardless of the per-env/config value. Break-glass is preserved: this
462
+ * NEVER disables `emailAndPassword.enabled`; it only forces `disableSignUp`
463
+ * and signals the UI to hide the password form. Generic over the IdP.
464
+ */
465
+ private resolveSsoOnly;
394
466
  getPublicConfig(): {
395
467
  emailPassword: {
396
468
  enabled: boolean;
@@ -417,10 +489,154 @@ declare class AuthManager {
417
489
  organization: boolean;
418
490
  multiOrgEnabled: boolean;
419
491
  oidcProvider: boolean;
492
+ sso: boolean;
493
+ ssoEnforced: boolean;
420
494
  deviceAuthorization: boolean;
421
495
  admin: boolean;
422
496
  };
423
497
  };
498
+ /**
499
+ * Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag.
500
+ * Resolved with the EXACT logic that decides whether the plugin is mounted
501
+ * in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the
502
+ * advertised capability can never disagree with the actual `/sign-in/sso`
503
+ * route. `OS_SSO_ENABLED` (when set) wins over the config-file setting.
504
+ */
505
+ private isSsoWired;
506
+ /**
507
+ * Whether enterprise SSO is actually *usable*, not merely wired: the plugin
508
+ * is on AND at least one `sys_sso_provider` row exists. Per-email domain→IdP
509
+ * matching still happens at `/sign-in/sso`; this answers the coarser "is
510
+ * there any point showing the SSO button at all", so a freshly-enabled but
511
+ * unconfigured SSO setup doesn't advertise a button that errors for everyone.
512
+ *
513
+ * Fails OPEN to the wired flag when providers can't be counted (no data
514
+ * engine, query error) — a config-introspection hiccup must never make the
515
+ * login page hide a button that genuinely works.
516
+ */
517
+ isSsoUsable(): Promise<boolean>;
518
+ /**
519
+ * Extra `trustedOrigins` entries derived from an external-SSO registration
520
+ * request. For a `POST /sso/register` | `/sso/update-provider`, parse the
521
+ * (cloned) body and return the PUBLIC-ROUTABLE origins of the declared
522
+ * `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery
523
+ * validation accepts a customer IdP registered at runtime (ADR-0024) without
524
+ * the operator pre-listing it in boot config. Only public-routable hosts are
525
+ * returned — private / internal / loopback hosts are never auto-trusted
526
+ * (better-auth's `isPublicRoutableHost`, the same predicate its own
527
+ * sub-endpoint check uses). Best-effort: any parse error yields `[]`.
528
+ */
529
+ private ssoDiscoveryTrustedOrigins;
530
+ /**
531
+ * Resolve the acting user (+ their active org) for a before-hook gate,
532
+ * hook-order-independent. Tries the standard cookie session first, then falls
533
+ * back to explicit token resolution (bearer or the session cookie's token
534
+ * part) — the bearer plugin may convert `Authorization: Bearer` to a session
535
+ * AFTER this global before-hook runs. Returns `null` when no valid session
536
+ * can be resolved (→ caller lets `sessionMiddleware` issue the 401).
537
+ */
538
+ private resolveActor;
539
+ /**
540
+ * True when `userId` is a platform admin (a `sys_user_permission_set` row
541
+ * pointing at `admin_full_access` with `organization_id = null`) OR an
542
+ * owner/admin member of `activeOrgId` (any org membership with role
543
+ * owner/admin when no active org is set). Mirrors the role-derivation in
544
+ * `customSession`; reads through `withSystemReadContext` so the lookups are
545
+ * not themselves RLS-scoped to the acting (possibly non-privileged) user.
546
+ * Fails CLOSED (returns false) on any lookup error — this backs a security
547
+ * gate, so an unverifiable actor must never pass.
548
+ */
549
+ private isOrgOrPlatformAdmin;
550
+ /**
551
+ * Compose the framework's identity-source stamp (`account.create.after`)
552
+ * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes
553
+ * `user.create.after` (personal-org provisioning) + `session.create.before`
554
+ * (active-org) — different model/op, so no collision — but if a host ever
555
+ * adds its own `account.create.after` we chain it after the stamp rather
556
+ * than silently dropping one.
557
+ */
558
+ private composeDatabaseHooks;
559
+ /**
560
+ * Maintain `sys_user.source` (ADR-0024 D4 provenance) as accounts are linked.
561
+ * Drives the managed-vs-native user-mgmt gating: a managed (`idp-provisioned`)
562
+ * user holds no local credential, so the password / identity-edit actions
563
+ * hide for them — preventing a managed user from self-minting a local
564
+ * password that would bypass enforced SSO.
565
+ *
566
+ * Two cases, both break-glass safe and idempotent (only writes on a real
567
+ * change, so trackHistory stays quiet):
568
+ *
569
+ * • A **federated** account (any non-`credential` provider — the cloud-as-IdP
570
+ * `objectstack-cloud` provider OR a customer's own OIDC/SAML IdP) is
571
+ * linked AND the user holds NO local credential → mark `idp-provisioned`.
572
+ * A user who already has a `credential` account (an env-native user who
573
+ * linked SSO) is left `env-native` — they keep a usable password.
574
+ *
575
+ * • A **credential** account is created (local signup, or the break-glass
576
+ * owner's password set via set-initial-password — which can land AFTER the
577
+ * first SSO link) → ensure `env-native`. This flips a previously-stamped
578
+ * owner back, so the break-glass admin never loses self-service password
579
+ * management.
580
+ *
581
+ * Best-effort: any failure leaves the prior value (the gate fails open — a
582
+ * managed user might transiently show a password action that simply errors —
583
+ * never a hard login failure).
584
+ */
585
+ private stampIdentitySource;
586
+ /**
587
+ * ADR-0069 D1 — reject a password that doesn't meet the configured character-
588
+ * class complexity. No-op when `passwordRequireComplexity` is off. Counts the
589
+ * four classes (upper / lower / digit / symbol) present and throws
590
+ * `PASSWORD_POLICY_VIOLATION` when fewer than `passwordMinClasses` are used.
591
+ */
592
+ private assertPasswordComplexity;
593
+ /**
594
+ * ADR-0069 D1 — parse the bounded `previous_password_hashes` JSON column into
595
+ * a string[] of hashes, tolerating null / malformed values.
596
+ */
597
+ private parseHashes;
598
+ /**
599
+ * ADR-0069 D1 — resolve the user whose password is being changed. For
600
+ * `/change-password` the caller is authenticated (session); for
601
+ * `/reset-password` the user is carried by the reset token's verification
602
+ * value (the same lookup better-auth's own handler uses).
603
+ */
604
+ private resolvePasswordChangeUserId;
605
+ /**
606
+ * ADR-0069 D1 — throw `PASSWORD_REUSE` when `candidate` matches the user's
607
+ * current password or any hash in the bounded history. Reuses better-auth's
608
+ * native `password.verify` (passed in) rather than re-hashing. Returns the
609
+ * current hash (for the after-hook to append) when the candidate is fresh, or
610
+ * undefined when the feature is off / nothing to compare.
611
+ */
612
+ private assertPasswordNotReused;
613
+ /**
614
+ * ADR-0069 D1 — append `oldHash` to the bounded password-history ring after a
615
+ * successful change/reset. Best-effort; never throws.
616
+ */
617
+ private recordPasswordHistory;
618
+ /**
619
+ * ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
620
+ * out (brute-force protection). No-op when lockout is disabled
621
+ * (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a
622
+ * lookup error: an infra hiccup must never block every login.
623
+ */
624
+ private assertAccountNotLocked;
625
+ /**
626
+ * ADR-0069 D2 — record a sign-in outcome for lockout accounting. On failure
627
+ * increments `failed_login_count` and, once it reaches `lockoutThreshold`,
628
+ * stamps `locked_until = now + lockoutDurationMinutes`. On success resets
629
+ * both (only writing when there is something to clear, to avoid a no-op
630
+ * history row on every login). No-op when lockout is disabled. Never throws —
631
+ * a counter write must not turn a valid login into an error.
632
+ */
633
+ private recordSignInOutcome;
634
+ /**
635
+ * ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
636
+ * Resets `failed_login_count` and `locked_until`. Returns false when no data
637
+ * engine is wired or the user does not exist.
638
+ */
639
+ unlockUser(userId: string): Promise<boolean>;
424
640
  /**
425
641
  * Returns the data engine wired into this auth manager. Used by route
426
642
  * handlers (e.g. bootstrap-status) that need to query identity tables
@@ -492,6 +708,18 @@ declare const AUTH_MODEL_TO_PROTOCOL: Record<string, string>;
492
708
  * Falls back to the original model name for custom / non-core models.
493
709
  */
494
710
  declare function resolveProtocolName(model: string): string;
711
+ /**
712
+ * Wrap a data engine so its READ operations (find / findOne / count) run as
713
+ * SYSTEM reads — injecting `context.isSystem: true` (merged; any caller-supplied
714
+ * context still wins on other keys). better-auth has already authenticated the
715
+ * session and scopes every query by its OWN where-clauses (e.g. member.userId =
716
+ * session.user). A deployment's control-plane org-scope read hook, however, keys
717
+ * off the CALLER's user id, and these adapter reads carry no caller context — so
718
+ * without isSystem that hook filters sys_member / sys_organization reads down to
719
+ * zero and `organization.list()` returns no orgs for a real member. Writes pass
720
+ * through untouched (org-scope is a read-only hook).
721
+ */
722
+ declare function withSystemReadContext(engine: IDataEngine): IDataEngine;
495
723
  /**
496
724
  * Create an ObjectQL adapter **factory** for better-auth.
497
725
  *
@@ -508,7 +736,7 @@ declare function resolveProtocolName(model: string): string;
508
736
  * @param dataEngine - ObjectQL data engine instance
509
737
  * @returns better-auth AdapterFactory
510
738
  */
511
- declare function createObjectQLAdapterFactory(dataEngine: IDataEngine): better_auth_adapters.AdapterFactory<better_auth.BetterAuthOptions>;
739
+ declare function createObjectQLAdapterFactory(rawDataEngine: IDataEngine): better_auth_adapters.AdapterFactory<better_auth.BetterAuthOptions>;
512
740
  /**
513
741
  * Create a raw ObjectQL adapter for better-auth (without factory wrapping).
514
742
  *
@@ -523,7 +751,7 @@ declare function createObjectQLAdapterFactory(dataEngine: IDataEngine): better_a
523
751
  * @param dataEngine - ObjectQL data engine instance
524
752
  * @returns better-auth CustomAdapter (raw, without factory wrapping)
525
753
  */
526
- declare function createObjectQLAdapter(dataEngine: IDataEngine): {
754
+ declare function createObjectQLAdapter(rawDataEngine: IDataEngine): {
527
755
  create: <T extends Record<string, any>>({ model, data, select: _select }: {
528
756
  model: string;
529
757
  data: T;
@@ -1225,6 +1453,59 @@ declare function buildOauthProviderPluginSchema(): {
1225
1453
  * from the deprecated `better-auth/plugins/oidc-provider` plugin.
1226
1454
  */
1227
1455
  declare const buildOidcProviderPluginSchema: typeof buildOauthProviderPluginSchema;
1456
+ /**
1457
+ * `@better-auth/sso` plugin `ssoProvider` model mapping.
1458
+ *
1459
+ * Each row is an external OIDC/SAML IdP this environment federates login to
1460
+ * (the relying-party side — ADR-0024's OPEN per-env SSO mechanism). The
1461
+ * protocol detail lives in JSON blobs (`oidcConfig` / `samlConfig`); the model
1462
+ * itself is thin. Mirrors @better-auth/sso@1.6.20's `BaseSSOProvider`.
1463
+ *
1464
+ * | camelCase (better-auth) | snake_case (ObjectStack) |
1465
+ * |:------------------------|:-------------------------|
1466
+ * | providerId | provider_id |
1467
+ * | oidcConfig | oidc_config |
1468
+ * | samlConfig | saml_config |
1469
+ * | userId | user_id |
1470
+ * | organizationId | organization_id |
1471
+ * | issuer / domain | (same name — no remap) |
1472
+ */
1473
+ declare const AUTH_SSO_PROVIDER_SCHEMA: {
1474
+ readonly modelName: "sys_sso_provider";
1475
+ readonly fields: {
1476
+ readonly providerId: "provider_id";
1477
+ readonly oidcConfig: "oidc_config";
1478
+ readonly samlConfig: "saml_config";
1479
+ readonly userId: "user_id";
1480
+ readonly organizationId: "organization_id";
1481
+ };
1482
+ };
1483
+ /**
1484
+ * `@better-auth/scim` plugin `scimProvider` model mapping.
1485
+ *
1486
+ * Each row is a SCIM connection: a bearer token an external IdP (Okta / Entra)
1487
+ * uses to auto-provision / deprovision THIS environment's users — the env is
1488
+ * the SCIM Service Provider (ADR-0071). Like `@better-auth/sso`, the plugin
1489
+ * hardcodes its model and exposes NO `schema` option, so the mapping is
1490
+ * consumed at the ADAPTER layer (AUTH_MODEL_TO_PROTOCOL + field resolution in
1491
+ * objectql-adapter.ts), NOT handed to the plugin.
1492
+ *
1493
+ * | camelCase (better-auth) | snake_case (ObjectStack) |
1494
+ * |:------------------------|:-------------------------|
1495
+ * | providerId | provider_id |
1496
+ * | scimToken | scim_token |
1497
+ * | organizationId | organization_id |
1498
+ * | userId | user_id |
1499
+ */
1500
+ declare const AUTH_SCIM_PROVIDER_SCHEMA: {
1501
+ readonly modelName: "sys_scim_provider";
1502
+ readonly fields: {
1503
+ readonly providerId: "provider_id";
1504
+ readonly scimToken: "scim_token";
1505
+ readonly organizationId: "organization_id";
1506
+ readonly userId: "user_id";
1507
+ };
1508
+ };
1228
1509
  /**
1229
1510
  * Builds the `schema` option for better-auth's `deviceAuthorization()` plugin.
1230
1511
  *
@@ -1249,4 +1530,4 @@ declare function buildDeviceAuthorizationPluginSchema(): {
1249
1530
  };
1250
1531
  };
1251
1532
 
1252
- export { AUTH_ACCOUNT_CONFIG, AUTH_ADMIN_SESSION_FIELDS, AUTH_ADMIN_USER_FIELDS, AUTH_DEVICE_CODE_SCHEMA, AUTH_INVITATION_SCHEMA, AUTH_JWKS_SCHEMA, AUTH_MEMBER_SCHEMA, AUTH_MODEL_TO_PROTOCOL, AUTH_OAUTH_ACCESS_TOKEN_SCHEMA, AUTH_OAUTH_APPLICATION_SCHEMA, AUTH_OAUTH_CLIENT_SCHEMA, AUTH_OAUTH_CONSENT_SCHEMA, AUTH_OAUTH_REFRESH_TOKEN_SCHEMA, AUTH_ORGANIZATION_SCHEMA, AUTH_ORG_SESSION_FIELDS, AUTH_SESSION_CONFIG, AUTH_TEAM_MEMBER_SCHEMA, AUTH_TEAM_SCHEMA, AUTH_TWO_FACTOR_SCHEMA, AUTH_TWO_FACTOR_USER_FIELDS, AUTH_USER_CONFIG, AUTH_VERIFICATION_CONFIG, AuthManager, type AuthManagerOptions, AuthPlugin, type AuthPluginOptions, type SetInitialPasswordResult, type SetPasswordCapableApi, buildAdminPluginSchema, buildDeviceAuthorizationPluginSchema, buildJwtPluginSchema, buildOauthProviderPluginSchema, buildOidcProviderPluginSchema, buildOrganizationPluginSchema, buildTwoFactorPluginSchema, createObjectQLAdapter, createObjectQLAdapterFactory, resolveProtocolName, runSetInitialPassword };
1533
+ export { AUTH_ACCOUNT_CONFIG, AUTH_ADMIN_SESSION_FIELDS, AUTH_ADMIN_USER_FIELDS, AUTH_DEVICE_CODE_SCHEMA, AUTH_INVITATION_SCHEMA, AUTH_JWKS_SCHEMA, AUTH_MEMBER_SCHEMA, AUTH_MODEL_TO_PROTOCOL, AUTH_OAUTH_ACCESS_TOKEN_SCHEMA, AUTH_OAUTH_APPLICATION_SCHEMA, AUTH_OAUTH_CLIENT_SCHEMA, AUTH_OAUTH_CONSENT_SCHEMA, AUTH_OAUTH_REFRESH_TOKEN_SCHEMA, AUTH_ORGANIZATION_SCHEMA, AUTH_ORG_SESSION_FIELDS, AUTH_SCIM_PROVIDER_SCHEMA, AUTH_SESSION_CONFIG, AUTH_SSO_PROVIDER_SCHEMA, AUTH_TEAM_MEMBER_SCHEMA, AUTH_TEAM_SCHEMA, AUTH_TWO_FACTOR_SCHEMA, AUTH_TWO_FACTOR_USER_FIELDS, AUTH_USER_CONFIG, AUTH_VERIFICATION_CONFIG, AuthManager, type AuthManagerOptions, AuthPlugin, type AuthPluginOptions, type SetInitialPasswordResult, type SetPasswordCapableApi, buildAdminPluginSchema, buildDeviceAuthorizationPluginSchema, buildJwtPluginSchema, buildOauthProviderPluginSchema, buildOidcProviderPluginSchema, buildOrganizationPluginSchema, buildTwoFactorPluginSchema, createObjectQLAdapter, createObjectQLAdapterFactory, resolveProtocolName, runSetInitialPassword, withSystemReadContext };