From a2413e05ce692c7e8127d7aa891859c3c49ec611 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:08:56 +0000 Subject: [PATCH] refactor(plugin-auth): move the buildPluginList body into auth-manager-plugin-list.ts The body of AuthManager.buildPluginList() moves unedited into the module function buildPluginList, re-indented one level: the reset of the degraded-feature record and the JWT signing verdict, the enabled table with its env and config precedence and the SCIM coherence re-check, and every plugin in the same order (bearer, the organization factory's arrow, twoFactor, breached-password rejection, admin, phoneNumber, magicLink, the generic OAuth providers, the OIDC provider with its jwt plugin, sso, scim, device authorization and the custom session). The method keeps its place, docblock, signature and return type; it now builds one host of the 22 AuthManager members the body reads (18 methods as late-bound closures, config and degradedFeatures as getters, jwtSigningFailureReported and orgRolesMap as get/set pairs because the body writes them) and returns buildPluginList.call(host). The async moved from the method to the function. The organization factory's arrow, which cut 15 left in the body, moves with it and builds its host from this host, so it reaches the same members and the same field as before. readBooleanEnv and consentOrganizationOf, the two top-level declarations of auth-manager.ts the body reads that the file did not export, are exported in place and imported by value with the five that already were; src/index.ts names its auth-manager.ts exports one by one, so nothing new is published. Nine imported names leave auth-manager.ts with the body. Readers that follow it: the two plugin-factory scans read the module, and the population comments in auth-email-locale.test.ts name it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr --- .../plugin-auth/src/auth-email-locale.test.ts | 8 +- .../src/auth-manager-plugin-list.ts | 913 ++++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 849 +--------------- .../src/better-auth-schema-parity.test.ts | 2 +- .../src/managed-extension-fields.test.ts | 2 +- 5 files changed, 961 insertions(+), 813 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts diff --git a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts index 29d68c24c59..a98ed136cad 100644 --- a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts +++ b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts @@ -860,7 +860,8 @@ describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", () * implied: the auth mail surface is the five `sendTemplate` call sites in * `auth-manager.ts`, `auth-manager-auth-instance.ts` (the body of * `createAuthInstance`), `auth-manager-organization-plugin.ts` (the body of - * the organization plugin's factory) and `auth-manager-email-sms-delivery.ts` + * the organization plugin's factory), `auth-manager-plugin-list.ts` (the body + * of `buildPluginList`) and `auth-manager-email-sms-delivery.ts` * (the body of the change-email notice) — `IEmailService` declares exactly two delivering * members (`send`, `sendTemplate`), plugin-auth's only `.send(` calls are the * SMS service's, and no other file under `plugin-auth/src` calls either. @@ -1125,8 +1126,9 @@ describe('#15106 — the CLASS claim: all five auth sends read the recipient col it('every one of the five sends is written in the stored language of its own recipient', async () => { // ⚠️ POPULATION: the five `sendTemplate` sites in `auth-manager.ts`, - // `auth-manager-auth-instance.ts`, `auth-manager-organization-plugin.ts` and - // `auth-manager-email-sms-delivery.ts` — the same five `driveAllFive` drives and `TEMPLATES` names, and the whole of + // `auth-manager-auth-instance.ts`, `auth-manager-organization-plugin.ts`, + // `auth-manager-plugin-list.ts` and `auth-manager-email-sms-delivery.ts` — the + // same five `driveAllFive` drives and `TEMPLATES` names, and the whole of // plugin-auth's mail surface (see this section's header). This is the pin // that closes the class; the per-send pins above only cover one member. // diff --git a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts new file mode 100644 index 00000000000..882e81ba636 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts @@ -0,0 +1,913 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25224 cut 16] The better-auth plugin list `AuthManager` builds — the body + * of its private `buildPluginList()` method, which `createAuthInstance` calls + * once per build — moved out of `auth-manager.ts` whole. + * + * A MOVE, not a redesign. The function below is that method's body as it + * stood, re-indented one level and otherwise unedited: the same reset of the + * degraded-feature record and the JWT signing verdict, the same `enabled` + * table (the env and config precedence it documents, the SCIM coherence + * re-check), and the same plugins in the same order — `bearer`, the + * `organization` factory, `twoFactor`, breached-password rejection, `admin`, + * `phoneNumber`, `magicLink`, the generic OAuth providers, the OIDC provider + * with its `jwt` plugin, `sso`, `scim`, device authorization and the custom + * session — each with its own failure tier, as the method's docblock states. + * The `organization` factory's body was moved out by cut 15; its arrow, which + * hands that body its host, moves here with the rest. + * + * ## How the moved body reaches the class + * + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` + * header carries the full reasoning), which the fifteen `auth-manager-*.ts` + * modules split out before this one follow. `buildPluginList` keeps its place, + * its docblock, its signature and its return type in the class; its body is + * now one literal of exactly the {@link PluginListHost} members the body + * reads, plus a `.call`. The `async` moved from the method to the function + * below, and the method returns the function's own promise. + * + * The body's `this` was the manager; it is now that host. Every member it + * reads but four is a method, and each goes as a closure that calls it on the + * manager at call time, so a test double or spy set on the manager is still + * the one the body reaches — including from the callbacks it hands + * better-auth, which run long after this function returns. Of the four + * fields, two go as GETTERS: `config`, because the class reassigns it after + * construction (`setRuntimeBaseUrl` and `applyConfigPatch`), and + * `degradedFeatures`, as every field this series carries goes. Two go as a + * GET/SET pair, because the body writes them: `jwtSigningFailureReported`, + * which it resets, and `orgRolesMap`, which the `organization` arrow's host + * writes through. Each setter writes the manager's own field, so every reader + * of those fields reads what the body wrote, at the same moment as before. + * The method builds the host each time it is called, as every method stub in + * this series does. + * + * The `organization` arrow builds its own host from `this`, which is now this + * host: each of its closures calls this host's closure, which calls the + * manager's method, and its getter and setter go through this host's pair to + * the manager's field, so it reaches the same member at the same moment as + * before. `readBooleanEnv` and `consentOrganizationOf` are the two top-level + * declarations of `auth-manager.ts` the body reads that were not exported; + * they are exported in place there, per ruling 6364006498 (A), and imported + * here by value with the five that already were. `src/index.ts` names its + * `auth-manager.ts` exports one by one, so those exports publish nothing. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { AuthPluginConfig } from '@objectstack/spec/system'; +import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { applyPlatformAdminImpersonation } from './admin-impersonate-endpoint.js'; +import { buildOrganizationPlugin, type OrganizationPluginHost } from './auth-manager-organization-plugin.js'; +import { + OAUTH_ORGANIZATION_CLAIM, + assertScimAdminCoherence, + consentOrganizationOf, + readBooleanEnv, + resolveDcrEnabled, + resolveOidcProviderEnabled, + resolveScimEnabled, + type AuthManager, +} from './auth-manager.js'; +import { + buildAdminPluginSchema, + buildDeviceAuthorizationPluginSchema, + buildOauthProviderPluginSchema, + buildPhoneNumberPluginSchema, + buildTwoFactorPluginSchema, +} from './auth-schema-config.js'; +import { isPlaceholderEmail } from './placeholder-email.js'; + +/** + * The `AuthManager` members the function below reads — every one of them, and + * no other. Each is typed AS the class member (an indexed access on the + * class), so a change to a member's signature on the class is a change here + * too, and naming a member the class does not have is a compile error. + * `config` and `degradedFeatures` are read-only here: the host hands them out + * through getters. `jwtSigningFailureReported` and `orgRolesMap` are written + * through, so the host hands each out as a getter and a setter over the + * manager's own field. + */ +export interface PluginListHost { + addOptionalPlugin: AuthManager['addOptionalPlugin']; + buildJwtPlugin: AuthManager['buildJwtPlugin']; + computeAuthGate: AuthManager['computeAuthGate']; + readonly config: AuthManager['config']; + readonly degradedFeatures: AuthManager['degradedFeatures']; + deliverPhoneOtp: AuthManager['deliverPhoneOtp']; + emailLocaleArg: AuthManager['emailLocaleArg']; + enforceSessionControls: AuthManager['enforceSessionControls']; + getAppName: AuthManager['getAppName']; + getConsolePageUrl: AuthManager['getConsolePageUrl']; + getDataEngine: AuthManager['getDataEngine']; + getEmailService: AuthManager['getEmailService']; + getMcpResourceUrl: AuthManager['getMcpResourceUrl']; + isPlatformAdminUserId: AuthManager['isPlatformAdminUserId']; + jwtSigningFailureReported: AuthManager['jwtSigningFailureReported']; + multiOrgPostureEffective: AuthManager['multiOrgPostureEffective']; + orgRolesMap: AuthManager['orgRolesMap']; + reconcileScimUserLifecycle: AuthManager['reconcileScimUserLifecycle']; + resolveAuthSecret: AuthManager['resolveAuthSecret']; + resolveMembershipRole: AuthManager['resolveMembershipRole']; + resolveTwoFactorAccountLockout: AuthManager['resolveTwoFactorAccountLockout']; + storedRecipientLocale: AuthManager['storedRecipientLocale']; +} + +/** + * The body of `AuthManager.buildPluginList()`; the method's docblock and the + * comments in it are its contract. + */ +export async function buildPluginList(this: PluginListHost): Promise { + this.degradedFeatures.clear(); + // Same lifecycle as degradedFeatures: an operator who fixes the cause and + // triggers a rebuild gets a fresh verdict, and a fresh log line if it is + // still broken. (The Ed25519 probe itself is NOT reset — it is a property + // of the runtime, which a rebuild cannot change.) + this.jwtSigningFailureReported = false; + const pluginConfig: Partial = this.config.plugins ?? {}; + const plugins: any[] = []; + + // Defaults — kept in sync with `AuthPluginConfigSchema` in + // @objectstack/spec/system/auth-config.zod.ts. The frontend AuthProvider + // (in @object-ui/app-shell) calls `/api/v1/auth/organization/list` on + // every load; making the org plugin opt-out (default true) avoids + // 404s and the noisy "Failed to load organizations" warning. + // + // `OS_OIDC_PROVIDER_ENABLED` lets operators flip the OIDC IdP on + // without re-deploying with a code change (mirrors the + // `OS_MULTI_ORG_ENABLED` / `OS_DISABLE_SIGNUP` pattern). When set, the + // env var WINS over the config-file setting so platform operators can + // override per-environment without touching the application bundle. + // Use the shared `readBooleanEnv` parser (same as OS_AUTH_TWO_FACTOR / + // OS_AUTH_PASSWORD_REJECT_BREACHED / OS_DISABLE_SIGNUP) so these accept the + // platform-standard truthy set (`true`/`1`/`yes`/`on`, case-insensitive) + // instead of only the literal string `'true'` — a repeated operator footgun + // (`OS_SSO_ENABLED=1` silently parsed as disabled). + // + // Precedence for `scim` / `sso` / `ssoDomainVerification` (#13439, + // maintainer ruling 2026-08-31): an EXPLICIT config value wins over the + // env var; the env var decides only where the config leaves the key unset + // (tri-state `z.boolean().optional()` in AuthPluginConfigSchema). This is + // deliberately the OPPOSITE of the env-wins order the OIDC/2FA/HIBP keys + // above keep: for these three, config is how the code constructing the + // AuthPlugin states a value the deployment env must not silently outrank + // (the cloud control plane's plan-derived `plugins.scim` is the known + // writer), while the operator per-environment override survives for every + // deployment that leaves the keys unset. + const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); + // Opt-in DNS domain-verification for external SSO providers (ADR-0135 D6). + // OFF by default → today's behavior exactly (register → login immediately). + // ON → @better-auth/sso mounts /sso/{request-domain-verification,verify-domain} + // AND enforces a HARD login gate: a provider whose domain is not DNS-verified + // rejects logins ("Provider domain has not been verified"). The two are + // coupled in @better-auth/sso (the endpoints only register when + // `domainVerification.enabled`), so this single flag governs both. Requires + // `OS_SSO_ENABLED` (the sso plugin must be loaded to honor it). + const ssoDomainVerifyFromEnv = readBooleanEnv('OS_SSO_DOMAIN_VERIFICATION'); + // @better-auth/scim's `active:false` → ban runs through the admin plugin, + // and org-scoped tokens need the organization plugin — so enabling SCIM + // forces `admin` on (organization already defaults on). See ADR-0134. + // An explicit `plugins.admin: false` beside effective SCIM is a + // contradiction refused below (#13816) — never silently honoured. + const scimEffective = resolveScimEnabled(pluginConfig); + // Re-checked here (the constructor already refused a conflicted BOOT + // config) because this is where the env var is actually read: an + // OS_SCIM_ENABLED that appears between construction and the lazy + // better-auth build would otherwise mount SCIM with its ADR-0134 + // deprovisioning path silently declined. + assertScimAdminCoherence(pluginConfig); + const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); + const hibpFromEnv = readBooleanEnv('OS_AUTH_PASSWORD_REJECT_BREACHED'); + const enabled = { + organization: pluginConfig.organization ?? true, + twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false, + passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false, + passkeys: pluginConfig.passkeys ?? false, + magicLink: pluginConfig.magicLink ?? false, + // Shared decision point (env → config → follows OS_MCP_SERVER_ENABLED), + // see resolveOidcProviderEnabled — keep auth-plugin.ts / features in sync. + oidcProvider: resolveOidcProviderEnabled(pluginConfig), + deviceAuthorization: pluginConfig.deviceAuthorization ?? false, + admin: pluginConfig.admin ?? scimEffective, + // #2766 V1.5 — phone+password sign-in. Opt-in; OTP flows stay off until + // SMS infrastructure exists (tracked separately). + phoneNumber: (pluginConfig as any).phoneNumber ?? false, + sso: pluginConfig.sso ?? ssoFromEnv ?? false, + ssoDomainVerification: pluginConfig.ssoDomainVerification ?? ssoDomainVerifyFromEnv ?? false, + scim: scimEffective, + }; + + // bearer() — ALWAYS enabled. + // + // Enables token-based authentication for cross-origin and mobile clients + // where third-party cookies are blocked (e.g. Safari ITP, Chrome CHIPS, + // native apps). The plugin: + // • Accepts `Authorization: Bearer ` on incoming requests and + // transparently resolves the session as if a cookie had been sent. + // • Emits a `set-auth-token` response header on sign-in / session-refresh + // that the client can store (e.g. in `localStorage`) and replay on + // subsequent requests. + // + // This mirrors how Salesforce, Notion, Supabase and first-party mobile + // SDKs handle auth. Cookie-based auth remains available for same-origin + // browser deployments; bearer is additive, not a replacement. + const { bearer } = await import('better-auth/plugins/bearer'); + plugins.push(bearer()); + + if (enabled.organization) { + // [#25224 cut 15] The body of this factory moved to + // `auth-manager-organization-plugin.ts`; this arrow hands it the members + // it reads (`OrganizationPluginHost`). + await this.addOptionalPlugin(plugins, 'organization', () => { + const readConfig = () => this.config; + const readOrgRolesMap = () => this.orgRolesMap; + const writeOrgRolesMap = (v: AuthManager['orgRolesMap']) => { this.orgRolesMap = v; }; + const host: OrganizationPluginHost = { + get config() { return readConfig(); }, + emailLocaleArg: (...a) => this.emailLocaleArg(...a), + getAppName: (...a) => this.getAppName(...a), + getConsolePageUrl: (...a) => this.getConsolePageUrl(...a), + getDataEngine: (...a) => this.getDataEngine(...a), + getEmailService: (...a) => this.getEmailService(...a), + multiOrgPostureEffective: (...a) => this.multiOrgPostureEffective(...a), + get orgRolesMap() { return readOrgRolesMap(); }, + set orgRolesMap(v) { writeOrgRolesMap(v); }, + resolveMembershipRole: (...a) => this.resolveMembershipRole(...a), + storedRecipientLocale: (...a) => this.storedRecipientLocale(...a), + }; + return buildOrganizationPlugin.call(host); + }); + } + + if (enabled.twoFactor) { + const { twoFactor } = await import('better-auth/plugins/two-factor'); + plugins.push(twoFactor({ + schema: buildTwoFactorPluginSchema(), + // ADR-0069 D2 (#3690) — the operator's lockout policy governs BOTH + // stages of sign-in, not just the password one. + accountLockout: this.resolveTwoFactorAccountLockout(), + })); + } + + // Breached-password rejection (ADR-0069 D1). Native, stateless: a + // k-anonymity range check against Have I Been Pwned on better-auth's + // password-mutating endpoints (sign-up / change / reset — the plugin's + // defaults). The plaintext password is never sent; only the first 5 SHA-1 + // hex chars leave the process. Rejects with PASSWORD_COMPROMISED. + if (enabled.passwordRejectBreached) { + const { haveIBeenPwned } = await import('better-auth/plugins/haveibeenpwned'); + plugins.push(haveIBeenPwned({ + customPasswordCompromisedMessage: + 'This password has appeared in a known data breach. Please choose a different one.', + })); + } + + if (enabled.admin) { + await this.addOptionalPlugin(plugins, 'admin', async () => { + const { admin } = await import('better-auth/plugins/admin'); + // Platform admin: ban/unban, set-password, impersonate, set-role. + // Schema mapping ensures the plugin's added user/session columns + // match ObjectStack's snake_case conventions (ban_reason, + // ban_expires, impersonated_by). `role` and `banned` are already + // snake_case-compatible. + const adminPlugin: any = admin({ + schema: buildAdminPluginSchema(), + }); + + // ADR-0068 D2 — re-authorize `/admin/impersonate-user` on ObjectStack's + // platform-admin predicate, IN PLACE on this plugin's own endpoints + // record. See `admin-impersonate-endpoint.ts` for the measurement that + // picked this shape over a second plugin (which boots and serves, but + // makes `checkEndpointConflicts` log an error on every start) and over + // a raw Hono mount (forbidden: hand-rolled signed cookies, and it would + // silently detach the #8243 rotation hook keyed on this path). + // + // Every OTHER better-auth-native `/admin/*` route still gates on the + // legacy scalar and still refuses platform admins — that is the parent + // card's remaining surface, deliberately untouched here. (A caller the + // ADR-0069 policy holds is refused on those ahead of the vendor, by the + // global before-hook — `VENDOR_ROLE_SCALAR_ADMIN_PATHS`.) + // + // ADR-0069 D3 — the same endpoint refuses a caller the policy holds, + // asked of the session gate's own posture carrier (`computeAuthGate`, + // the first entry of `resolveUserAuthGates`); see the endpoint module. + const rewired = await applyPlatformAdminImpersonation( + adminPlugin, + (userId: string) => this.isPlatformAdminUserId(userId), + (userId: string, organizationId: string | undefined) => + this.computeAuthGate(userId, organizationId, false), + ); + if (!rewired) { + // The vendor renamed or dropped the endpoint. Say so loudly: the + // route then falls back to the vendor's own handler, which refuses + // every platform admin — a broken button, not an open door. + console.error( + '[AuthManager] better-auth\'s admin plugin no longer exposes a ' + + '`impersonateUser` endpoint at /admin/impersonate-user, so the ' + + 'ADR-0068 platform-admin authorization could NOT be applied. ' + + 'Impersonation will refuse every ObjectStack platform admin until ' + + 'admin-impersonate-endpoint.ts is updated for the new vendor shape.', + ); + } + return adminPlugin; + }); + } + + if (enabled.phoneNumber) { + await this.addOptionalPlugin(plugins, 'phoneNumber', async () => { + const { phoneNumber } = await import('better-auth/plugins/phone-number'); + // #2766 V1.5 wired phone+password sign-in; #2780 opens the OTP surface + // (`/phone-number/send-otp` + `/verify`, `/request-password-reset` + + // `/reset-password`) whenever an SMS service is available — resolved + // lazily per send so the plugin list stays stable while the capability + // upgrades at kernel:ready. Without one, sendOTP still fails loudly + // (400 SMS_SERVICE_REQUIRED) instead of silently logging. signUpOnVerification + // stays deliberately NOT configured — phone-only accounts are created + // by the admin create-user/import routes with a placeholder email + // (see placeholder-email.ts), never by OTP self-signup. + const otpCfg = this.config.phoneOtp ?? {}; + return phoneNumber({ + schema: buildPhoneNumberPluginSchema(), + // Wrong-code attempts before the stored OTP is invalidated. Explicit + // (even though 3 is the better-auth default) — #2780 names it a + // security requirement, so it must not drift with a dependency bump. + allowedAttempts: otpCfg.allowedAttempts ?? 3, + ...(otpCfg.expiresIn != null ? { expiresIn: otpCfg.expiresIn } : {}), + ...(otpCfg.otpLength != null ? { otpLength: otpCfg.otpLength } : {}), + // Reject garbage before an SMS is paid for (same shape rule as the + // admin endpoints' normalizePhoneNumber). + phoneNumberValidator: (phone: string) => + /^\+?[0-9]{6,15}$/.test(String(phone ?? '').replace(/[\s\-().]/g, '')), + // Sign-in / verify OTP. Throws surface to the endpoint (better-auth + // awaits this callback on /phone-number/send-otp), so the guard's + // TOO_MANY_REQUESTS becomes an honest 429 to the client. + sendOTP: async ({ phoneNumber: phone, code }) => { + await this.deliverPhoneOtp(phone, code); + }, + // Self-service password reset OTP (`/phone-number/request-password- + // reset`). better-auth invokes this via runInBackgroundOrAwait — + // errors are logged, the route still answers {status:true} — so a + // throw here can neither 500 the request nor leak whether the number + // is registered. + sendPasswordResetOTP: async ({ phoneNumber: phone, code }) => { + await this.deliverPhoneOtp(phone, code); + }, + }); + }); + } + + if (enabled.magicLink) { + await this.addOptionalPlugin(plugins, 'magicLink', async () => { + const { magicLink } = await import('better-auth/plugins/magic-link'); + // magic-link reuses the `verification` table — no extra schema mapping needed. + // + // The link's lifetime, in seconds, and the ONE source for it: the mount + // states it and the mail's `expiresInMinutes` is derived from it, so the + // copy says what the link does. 300 s is the lifetime the link already + // had through the vendor default (`opts.expiresIn || 300`, better-auth + // 1.7.12 `plugins/magic-link/index.mjs`), now written down so a + // dependency bump cannot move it under the mail. ⛔ Lengthening it + // relaxes a sign-in credential's validity — a security-boundary change + // for the maintainer, not a copy fix. + const magicLinkExpiresInSec = 5 * 60; + return magicLink({ + expiresIn: magicLinkExpiresInSec, + // #14319 — this one is called with the endpoint CTX, not a Request + // (measured; magic-link/index.mjs `sendMagicLink({...}, ctx)`). + sendMagicLink: async ({ email: recipientEmail, url, token }, ctx?: unknown) => { + // #2766 V1.5 — placeholder addresses are never real recipients. + if (isPlaceholderEmail(recipientEmail)) { + throw new Error( + `Magic-link email refused: ${recipientEmail} is a placeholder address (PLACEHOLDER_EMAIL).`, + ); + } + const emailService = this.getEmailService(); + if (!emailService) { + // #2766 — a magic link IS a session credential; only print it in + // dev, never in production logs. + const dev = (globalThis as any)?.process?.env?.NODE_ENV !== 'production'; + console.warn( + `[AuthManager] Magic-link requested for ${recipientEmail} but no email service is wired.` + + (dev ? ` URL: ${url}` : ' (link suppressed outside dev)'), + ); + return; + } + // #15106 — the recipient's OWN stored language, the last auth mail + // to reach the top rung of the #14788 option-D ladder. Same rung and + // same helper as the other four sends; only the predicate differs, + // because this callback is handed an address rather than a user row + // — the #14641 invitation shape, and for the same reason it has two + // branches: + // + // 1. the address HAS a `sys_user` row — the ordinary case, since a + // magic link is how an existing account signs in without a + // password → their own `locale`; + // 2. NO row — magic link doubles as sign-up (measured in the + // installed 1.7.x: `/sign-in/magic-link` sends WITHOUT checking + // for an account, and `/magic-link/verify` creates one unless + // `disableSignUp`), so an unknown address is legitimate here and + // its language is genuinely unknown → the rungs below stand. + // + // ⚠️ Lowercased, unlike the invitation predicate, and the difference + // is measured rather than stylistic: better-auth lowercases the + // invitee address on the invite route, but `signInMagicLinkBodySchema` + // applies no such transform, so what arrives here is whatever the + // caller typed. `internalAdapter.findUserByEmail` — the lookup + // `/magic-link/verify` resolves this very link with — matches on + // `email.toLowerCase()`. Reading the column on the same spelling + // keeps the row the mail is ABOUT identical to the row the link will + // sign into; an address that resolves nothing lands on the rungs + // below, which is the documented floor rather than a failure. + // + // ⛔ The request rung is NOT removed: ruling D inserts the column + // ABOVE `Accept-Language`, it does not replace it. A magic link is + // requested by its own recipient, so `ctx` here carries the + // recipient's own header and stays a legitimate second rung for an + // account that has stated no language. + const storedLocale = await this.storedRecipientLocale({ + email: recipientEmail.toLowerCase(), + }); + try { + await emailService.sendTemplate({ + template: 'auth.magic_link', + to: recipientEmail, + ...this.emailLocaleArg(ctx, storedLocale), + data: { + magicLinkUrl: url, + token, + expiresInMinutes: Math.round(magicLinkExpiresInSec / 60), + appName: this.getAppName(), + }, + // [ADR-0131 D7, Q2 A] A sign-in link for a person: identity mail. + identityMail: true, + }); + } catch (err: any) { + console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`); + throw err; + } + }, + }); + }); + } + + // OIDC / Generic OAuth2 providers (enterprise SSO via genericOAuth plugin) + if (this.config.oidcProviders?.length) { + const oidcProviders = this.config.oidcProviders; + await this.addOptionalPlugin(plugins, 'genericOAuth', async () => { + const { genericOAuth } = await import('better-auth/plugins/generic-oauth'); + return genericOAuth({ + config: oidcProviders.map(p => ({ + providerId: p.providerId, + ...(p.discoveryUrl ? { discoveryUrl: p.discoveryUrl } : {}), + ...(p.issuer ? { issuer: p.issuer } : {}), + ...(p.authorizationUrl ? { authorizationUrl: p.authorizationUrl } : {}), + ...(p.tokenUrl ? { tokenUrl: p.tokenUrl } : {}), + ...(p.userInfoUrl ? { userInfoUrl: p.userInfoUrl } : {}), + clientId: p.clientId, + clientSecret: p.clientSecret, + ...(p.scopes ? { scopes: p.scopes } : {}), + ...(p.pkce != null ? { pkce: p.pkce } : {}), + })), + }); + }); + } + + // OAuth/OIDC Provider — turn this server into an OpenID Connect Identity + // Provider so external apps can SSO via ObjectStack. Adds the + // `/oauth2/{authorize,token,userinfo,register,consent,endsession}` and + // `/.well-known/openid-configuration` endpoints under the auth route. + // + // Migrated from the deprecated `better-auth/plugins/oidc-provider` to the + // standalone `@better-auth/oauth-provider` package. The new plugin uses + // `oauthClient`, `oauthAccessToken`, `oauthRefreshToken`, and `oauthConsent` + // models — see `buildOauthProviderPluginSchema()` for the snake_case + // mappings to ObjectStack's `sys_oauth_*` tables. + if (enabled.oidcProvider) { + // jwt + oauthProvider are one atomic unit: on failure neither lands. + // This is the plugin that broke every fresh 15.1.0 project (modelName + // TypeError from a 1.6/1.7 version mix) — see addOptionalPlugin. + await this.addOptionalPlugin(plugins, 'oidcProvider', async () => { + // The new @better-auth/oauth-provider package requires the `jwt` + // plugin (used to sign id_tokens / JWT access tokens). Register it + // automatically — it is otherwise an internal implementation detail + // and forcing every consumer to opt in would be poor DX. + const { jwt } = await import('better-auth/plugins'); + const jwtPlugin = await this.buildJwtPlugin(jwt); + + const { oauthProvider } = await import('@better-auth/oauth-provider'); + const dcr = resolveDcrEnabled(pluginConfig); + return [jwtPlugin, oauthProvider({ + // Console SPA renders both pages (replaces the legacy Account SPA at + // /_account). Override `uiBasePath` in AuthConfig if Console is + // mounted elsewhere. + loginPage: this.getConsolePageUrl('/login'), + consentPage: this.getConsolePageUrl('/oauth/consent'), + schema: buildOauthProviderPluginSchema(), + // ⛔ No `silenceWarnings` here. It was added for the #3420 double + // print of oauth-provider's "Please ensure /.well-known/… exists" + // notice — a false positive, because registerOidcDiscoveryRoutes + // mounts those documents at the issuer ROOT where RFC 8414 §3 requires + // them. The pinned 1.7.2 emits no such notice: neither the option name + // nor the `oauthAuthServerConfig` key nor the notice text occurs + // anywhere in `@better-auth/oauth-provider` or `better-auth`, so the + // option silenced nothing and was the same dead-option shape as the + // `validAudiences` defect below. If a future bump reintroduces the + // notice, re-add the silencer with a fresh reading — do NOT restore it + // on the strength of this comment. + // ── MCP OAuth track (#2698) ──────────────────────────────── + // Coarse tool-family scopes for the platform's own MCP endpoint, + // advertised alongside the standard OIDC scopes. Names are + // single-sourced in @objectstack/spec so AS / resource server / + // tool layer cannot drift. + scopes: ['openid', 'profile', 'email', 'offline_access', ...MCP_OAUTH_SCOPES], + // MCP clients bind tokens to the resource via RFC 8707 + // (`resource=`). In @better-auth/oauth-provider 1.7.2 a + // requested `resource` is resolved from the `oauthResource` table + // (`sys_oauth_resource`) — a miss is refused at /oauth2/authorize with + // `invalid_target: requested resource is not configured` — and + // `enforcePerClientResources` defaults to TRUE, so the client must + // additionally be linked in `oauthClientResource` + // (`sys_oauth_client_resource`). Both rows have to exist before the + // first Connect, so both are declared here: + // + // • `resources` seeds the sys_oauth_resource row from the plugin's + // own `init` (idempotent, `resourceSeedMode: "insertOnly"` by + // default, so an admin's later CRUD edits are never reverted); + // • `clientRegistrationDefaultResources` links every newly + // registered client to it inside the DCR transaction — a client + // that registers anonymously one second before the login cannot + // be linked by an admin in between. + // + // ⛔ `enforcePerClientResources` is deliberately NOT passed: the + // per-client linkage check stays at its `true` default. The fix makes + // the link happen; it does not switch the check off. A client with no + // link row is still refused, and + // auth-manager.mcp-oauth-resource.test.ts asserts exactly that. + // + // ⛔ Do not reintroduce `validAudiences`: 1.7.2 reads no such option + // (0 occurrences in its dist), and audience validation now runs + // through the resource table instead. A field that is passed and read + // by nobody looks like configuration and enforces nothing — that is + // how this defect survived a version bump. + resources: [this.getMcpResourceUrl()], + clientRegistrationDefaultResources: [this.getMcpResourceUrl()], + // RFC 7591 Dynamic Client Registration. `allowUnauthenticated…` is + // required: MCP clients register BEFORE any user is logged in (the + // whole point of the self-serve flow). Registration is rate-limited + // by the plugin, clients get only the scopes advertised above, and + // every token still requires an interactive PKCE login + consent — + // an anonymous registration mints no authority by itself. + allowDynamicClientRegistration: dcr, + allowUnauthenticatedClientRegistration: dcr, + // ── Native MCP clients that omit `application_type` (#25118, ruling 6329352251, A1) ── + // Local MCP clients (Claude Code, Cursor, VS Code, Codex CLI) register + // over DCR with a loopback `http` or a custom-scheme redirect, as RFC + // 8252 §7.3 and the MCP authorization spec have native clients do, and + // send no `application_type`. The provider's default fills a missing + // type with `web` (OIDC Registration §2), whose redirect rule refuses + // both forms, so every such client was refused before its browser + // opened. + // + // `'infer'` is the provider's own classifier; there is no second rule + // here. A dynamic registration that omits the type becomes `native` + // when ANY redirect uses a non-http(s) scheme or `http` on exactly + // `localhost`, `127.0.0.1` or `[::1]`, and each redirect is then held to + // the native rule; otherwise it stays `web`. A type the client sends is + // never overridden. It acts on `/oauth2/register` only (the Setup + // form's managed `create-client` door is unaffected) and only while + // `dcr` is on: with DCR off the provider refuses every registration. + // + // The measured boundary, pinned on the real provider in + // auth-manager.mcp-oauth-dcr-infer.test.ts: + // • a public client with a loopback or custom-scheme redirect → `native`; + // • `https://evil.example/cb` alone stays `web`; `http://evil.example/cb`, + // `http://localhost.evil.example`, and an explicit `web` with a + // loopback redirect stay refused; + // • NOT "public clients only": the classifier reads redirect URIs and + // never `token_endpoint_auth_method`, so a CONFIDENTIAL registration + // (`client_secret_basic`, or the method omitted, which the provider + // fills as `client_secret_basic`) with a loopback or custom-scheme + // redirect and no type also registers as `native` and is issued a + // `client_secret`. That body was already accepted with + // `application_type: 'native'` spelled out, so no registrable client + // shape is added; only the meaning of an omitted field changes + // (RFC 8252 §8.4 allows a per-instance secret provisioned by DCR); + // • PKCE stays required at /oauth2/authorize for every one of these + // clients: unconditionally for a public client, and for a + // confidential DCR client because `clientRegistrationRequirePKCE` + // keeps its `true` default. ⛔ Do not pass it as `false`. + // + // ⛔ No hook on `/oauth2/register` and no second classification rule + // beside this one: ruling A1 declined both. + clientRegistrationDefaultApplicationType: 'infer', + // ── The organization an OAuth consent binds (ruling 6232069920, A) ── + // At consent the consenting session's active organization becomes the + // consent's `reference_id`, and the installed provider carries that + // value onto the authorization code, the refresh family it starts, and + // every access token minted from either, where `customAccessTokenClaims` + // emits it as `organization_id` (`verifyMcpAccessToken` reads it back). + // + // ⛔ The value is the SESSION's, never the client's: the hook reads the + // session the provider hands it and nothing else. The authorize query, + // the consent body and the client's metadata never reach it, so no + // request parameter can choose the organization a token acts in. + // + // ⛔ No session organization ⇒ no reference and no claim. Never a + // guessed or default one: the principal then resolves with global + // grants only. + // + // Existing consents re-confirm once, by the provider's own lookup: it + // finds a consent by (client, user, reference) whenever this hook + // answers a reference, so a consent stored before this binding (its + // `reference_id` is null) no longer satisfies an authorize made in an + // organization, and the user sees the consent page again. Until then an + // old refresh family keeps minting tokens with no claim, because a + // refresh carries the family's own reference, never the session's. + // + // `postLogin` is declared as one object, so its two other members are + // required beside the hook. `shouldRedirect` answers false: the + // organization is the one the session already has, not a choice made + // on an extra page. `page` is read only when `shouldRedirect` answers + // true, which it never does here; it names the consent page so that it + // could only ever point at a page that exists. + postLogin: { + page: this.getConsolePageUrl('/oauth/consent'), + shouldRedirect: () => false, + consentReferenceId: ({ session }) => consentOrganizationOf(session), + }, + // `organization_id` is not in the provider's reserved access-token + // claim set, which it strips at issuance. This is the claim the + // `IAuthService.verifyMcpAccessToken` contract names. + customAccessTokenClaims: ({ referenceId }) => + referenceId ? { [OAUTH_ORGANIZATION_CLAIM]: referenceId } : {}, + })]; + }); + } + + // External SSO (OIDC / SAML) relying-party — lets this environment federate + // login to a customer's own IdP (Okta / Entra / Google …). Per-env, runtime- + // registered providers live in `sys_sso_provider` (ADR-0135 D6: the OPEN SSO + // mechanism — cloud-free for self-host). Endpoints mount under + // /api/v1/auth/sso/{register,providers,delete-provider,callback,…}. + // + // Toggle with `OS_SSO_ENABLED` (mirrors `OS_OIDC_PROVIDER_ENABLED`). + if (enabled.sso) { + await this.addOptionalPlugin(plugins, 'sso', async () => { + const { sso } = await import('@better-auth/sso'); + // NOTE: the `ssoProvider` model is bridged to `sys_sso_provider` by the + // better-auth adapter / a global model map, not per-plugin here (see the + // `ssoProvider` NOTE in auth-schema-config.ts). + // + // That bridge dates from 1.6.20, where @better-auth/sso hardcoded the + // model and read no `schema` option. Re-measured against the installed + // 1.7.1 (`node_modules/@better-auth/sso/dist`) on 2026-08-19 — the pin + // moved off 1.7.0-rc.2, so the previous stamp here had itself expired + // (#8224): still no longer true — `SSOOptions.schema.ssoProvider` exists + // (index-CZytzKv6.d.mts) and the runtime honours `modelName` plus a + // per-field `fieldName` map (index.mjs, the plugin's `schema:` block). The + // adapter-level bridge is kept as-is here because it is what the rest of + // the auth stack is wired to; whether to move it onto the plugin option is + // a separate change, not a silent one. Only the mapping surface below was + // re-verified in depth — see register-sso-provider.ts for the + // `oidcConfig.mapping` strict-object findings. + // + // `organizationProvisioning.defaultRole` (cloud ADR-0024 V1): a first-time + // federated login is JIT-provisioned into the user's domain-matched org + // with this role, so a member who arrives via an external IdP lands with + // an explicit default role (belt-and-suspenders over SecurityPlugin's + // `member_default` fallback, which already grants baseline access to any + // authenticated user). Requires the `organization` plugin — on by default. + // `domainVerification.enabled` (ADR-0135 D6, opt-in via OS_SSO_DOMAIN_VERIFICATION): + // when on, @better-auth/sso mounts /sso/request-domain-verification + + // /sso/verify-domain (DNS TXT proof-of-ownership) AND enforces that an + // external IdP's email domain be DNS-verified before it may complete a + // login — preventing an org admin from registering a provider for a domain + // they don't control. Off by default to preserve the register→login flow. + return sso({ + organizationProvisioning: { defaultRole: 'member' }, + ...(enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}), + }); + }); + } + + // External SCIM 2.0 Service Provider (@better-auth/scim, MIT) — lets an + // external IdP (Okta / Entra) auto-provision / deprovision THIS env's users + // (the paid Identity lifecycle, cloud ADR-0071). The env is the SCIM Service + // Provider; SCIM 2.0 endpoints mount under /api/v1/auth/scim/v2/{Users,…}. + // `active:false` → ban + session revoke (needs the admin plugin, forced on + // above). This plugin still accepts no `schema` option (`SCIMOptions` + // declares no `schema` / `modelName` / `fields` member on the installed + // 1.7.1), so its seven models are bridged to the `sys_scim_*` platform + // objects via AUTH_MODEL_TO_PROTOCOL. Toggle with `OS_SCIM_ENABLED`. + // + // Connections are RUNTIME DATA, not boot config (#3653): the stable + // constructor's three-way requirement (static `connections` | bearer + // verifier | managed catalog) is satisfied with an application-owned + // `verifyBearerToken` that resolves the connection from a + // `sys_scim_connection_credential` row at request time. ObjectStack owns + // the whole credential lifecycle — mint / store / verify — in + // `scim-connection-service.ts`; only a keyed one-way digest is ever + // persisted (pinned by `credential-at-rest-posture.test.ts`). The rc.1 + // `storeSCIMToken` / `/scim/generate-token` surface no longer exists on + // stable, and the upstream `managedConnections` catalog is deliberately + // NOT adopted (maintainer ruling 2026-08-25). + if (enabled.scim) { + await this.addOptionalPlugin(plugins, 'scim', async () => { + const { scim } = await import('@better-auth/scim'); + const { verifyScimBearerToken } = await import('./scim-connection-service.js'); + const secret = this.resolveAuthSecret(); + return scim({ + connections: [], + authentication: { + verifyBearerToken: async (input) => { + // ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request + // scope that makes the adapter open a REAL engine transaction is + // opened by `handleRequest` with `run(...)` around the whole + // request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be + // stamped from this callback and never reached the writes: an + // `enterWith` marks only the async resource it runs in and that + // resource's descendants, and the vendor resumes the endpoint + // handler from a continuation captured BEFORE this verifier ran + // (measured on 1.7.2 — zero engine transactions across a SCIM + // POST + PATCH; pinned by `scim-transaction-scope.test.ts`). + const engine = this.config.dataEngine; + if (!engine) return null; // no store to verify against — fail closed + return verifyScimBearerToken(engine as never, secret, input.token); + }, + }, + // [#14360] The host half of `active`: stable @better-auth/scim + // writes no `banned` itself any more (the 1.6.x coupling left the + // package in 1.7.0) — it hands the aggregate lifecycle state to + // this callback inside the SCIM transaction and only revokes + // sessions. Routed to the platform's own ban write; the break-glass + // last-administrator guard judges it at the engine. See + // `reconcileScimUserLifecycle` for the contract and the measurement. + identity: { + reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context), + }, + }); + }); + } + + // Device Authorization Grant (RFC 8628) — for CLI / TV-style devices. + // Exposes the standard `/device/{code,token,approve,deny}` endpoints + // and persists pending requests in `sys_device_code`. + // + // The verification URI points at the account portal page that lets a + // signed-in user approve or deny a pending CLI login. The page reads + // the `user_code` query parameter that better-auth appends to + // `verification_uri_complete`. + if (enabled.deviceAuthorization) { + await this.addOptionalPlugin(plugins, 'deviceAuthorization', async () => { + const { deviceAuthorization } = await import('better-auth/plugins/device-authorization'); + return deviceAuthorization({ + verificationUri: this.getConsolePageUrl('/auth/device'), + schema: buildDeviceAuthorizationPluginSchema(), + }); + }); + } + + // customSession() — augments the session payload with the canonical + // `positions: string[]` array (ADR-0068 D1/D2, renamed ADR-0090 D3) and its + // derived `isPlatformAdmin` alias. + // + // [#15136] `positions[]` is the SECURITY axis — the same set + // `/auth/me/permissions` reports and every server-side evaluator resolves: + // the active organization's membership mapped to canonical + // `org_owner`/`org_admin`/`org_member`, the ADR-0057 D4 `sys_user_position` + // assignments, the ADR-0090 D5 `everyone` anchor, and `platform_admin`. It + // is not derived here; it is asked of `resolveUserAuthzGrants`, the ONE + // authority, for the reason spelled out at the call site below. + // + // ⛔ The better-auth `user.role` scalar is NOT a source of it. It used to be + // (split on commas and unioned in), which put auth-layer role names on an + // array whose declared meaning — `EvalUserSchema`, "built-in identity names + // + position names" — is the security axis, and left the real position names + // off it entirely. + // + // IMPORTANT: `user.role` is NOT overwritten — consumers must gate on + // `positions[]` / `isPlatformAdmin` (e.g. via objectui's + // `useWorkspaceAdminStatus`), never on `user.role === 'admin'`. A consumer + // that genuinely wants the better-auth role reads `user.role`, which stays + // on the payload verbatim; the raw membership role stays on the organization + // plugin's `member` payload. + const dataEngine = this.config.dataEngine; + if (dataEngine) { + const { customSession } = await import('better-auth/plugins/custom-session'); + plugins.push(customSession(async ({ user, session }) => { + if (!user?.id) return { user, session }; + + // ADR-0068 D1/D2 (renamed ADR-0090 D3) — emit ONE canonical + // positions[] (identity names + position names), with NO singular + // overwrite. isPlatformAdmin is a DERIVED alias of + // `'platform_admin' in positions`, retained for back-compat clients. + // [#10348] Asked through the ONE authority, exactly as `/sso/register` + // and `/admin/impersonate-user` ask it — so the session payload can no + // longer disagree with the gates about who a platform admin is. + // + // [#15136 — maintainer ruling 2026-09-05, option A] ONE name, ONE + // meaning: `positions[]` is the SECURITY axis on every surface. + // + // This used to be a hand-rolled union — the better-auth `sys_user.role` + // scalar split on commas, plus the active membership mapped to `org_*`, + // plus `platform_admin`. It read NOTHING from `sys_user_position`, the + // ADR-0057 D4 table that is the source of truth for custom positions, so + // a user genuinely holding a business position got a payload without it + // and every client-side gate narrowed by that position answered FALSE — + // silently, because the root and the key were both bound and CEL raised + // nothing. `EvalUserSchema` had declared the opposite all along + // ("built-in identity names + position names", "evaluates identically + // wherever it is written"); the Console binds this array straight through + // as `current_user`, so the payload WAS the contract violation. + // + // The derivation is not repaired here — it is DELETED and asked of the + // authority instead. `resolve-authz-context.ts` states that every entry + // point must resolve authorization through it and never re-read the + // `sys_*` grant tables itself; this callback was doing exactly what that + // forbids, which is how it drifted out of agreement with + // `/auth/me/permissions` (served from the same `grants.positions`) and + // with every server-side evaluator (`ExecutionContext.positions`) in the + // first place. The same move `isPlatformAdminUserId` made at #10348. + // + // Scoped to the session's ACTIVE organization, so a position held in one + // organization does not answer while the caller operates in another — + // the resolver's own rule, now applied to the payload too. + // + // `isPlatformAdmin` comes from the posture RUNG on the same envelope, + // never from the array. ⛔ `positions.includes('platform_admin')` is the + // form `resolve-authz-context.ts` explicitly forbids, because an + // ADR-0057 D4 `sys_user_position` row MAY SPELL THAT VERY NAME and a + // platform-RBAC assignment is not the ADR-0068 D2 capability grant. + // Under ruling A that stopped being theoretical: moving `positions` to + // the security axis moved the string `platform_admin` into a space a + // tenant admin can WRITE (`sys_user_position` is `apiEnabled`), so an + // array read here would have let a tenant mint platform standing and + // pass the `/admin/*` mount gate. `grants.posture === 'PLATFORM_ADMIN'` + // is byte-for-byte what `hasPlatformAdminStanding` returns, so the + // payload, that predicate and `judgePlatformAdmin` cannot disagree. + // ADR-0068 D2 defines the alias as `'platform_admin' in roles`; that + // wording predates D4 rows being able to spell built-in names, and + // core's later ⛔ is the specific rule. + // + // Fail CLOSED on an unreadable grant store, matching what both halves of + // the old derivation already did (`isPlatformAdminUserId` returns false + // on any lookup error; `activeOrgRoles` caught to `[]`). Warned rather + // than swallowed — an empty `positions[]` hides UI, and this card is + // about exactly that going unannounced. + // + // The seed email is the session's own, spelled as `resolveAuthzContext` + // spells it for this session (`String(user.email)` when present), so an + // in-process session read that `resolveAuthzContext` makes asks for + // EXACTLY the resolution its own step 2 asks for — and core's + // request-scoped memo serves that step this resolution instead of + // issuing every grant read twice per request. It shapes nothing read + // here: `grants.email` is the only output it reaches, and the + // platform-admin config anchor compares the STORED `sys_user.email`, + // never a seed (core §6b-config). + let positions: string[] = []; + let platformAdmin = false; + try { + const grants = await resolveUserAuthzGrants(dataEngine as any, user.id, { + tenantId: (session as any)?.activeOrganizationId ?? undefined, + seedEmail: user.email ? String(user.email) : undefined, + }); + positions = grants.positions; + platformAdmin = grants.posture === 'PLATFORM_ADMIN'; + } catch (err: any) { + console.warn( + '[auth] could not resolve authorization grants for the session payload; ' + + 'positions[] is empty and position-gated UI will be hidden:', + err?.message ?? String(err), + ); + } + + // ADR-0069 — authentication-policy gate posture (password expiry, + // enforced MFA). Computed only when a gate feature is enabled (else + // zero extra reads on the hot path); surfaced as `user.authGate` for + // the transport seams to enforce. See computeAuthGate(). + // ADR-0069 D4 — session controls (idle / absolute). Best-effort; + // revokes the session in place when exceeded (next request → 401). + await this.enforceSessionControls((session as any)?.id, (session as any)?.createdAt); + + // An impersonation session carries the administrator who started it + // (`impersonatedBy`, written by the admin plugin's impersonation and + // stored as `sys_session.impersonated_by`); which postures hold the + // session depends on it — see computeAuthGate(). + const impersonatedBy = (session as any)?.impersonatedBy; + const authGate = await this.computeAuthGate( + user.id, + (session as any)?.activeOrganizationId, + (user as any)?.twoFactorEnabled === true, + { impersonation: typeof impersonatedBy === 'string' && impersonatedBy.trim() !== '' }, + ); + + return { + user: { ...user, positions, isPlatformAdmin: platformAdmin, ...(authGate ? { authGate } : {}) }, + session, + }; + })); + } + + return plugins; +} diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 9acae45c5b2..f57bd24daf5 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -38,7 +38,6 @@ import { // [#25083] Is this process a cloud-managed (hosted) environment? The ONE // accessor every managed-mode judgement here asks, and nothing else. isCloudManaged, - resolveUserAuthzGrants, } from '@objectstack/core'; import type { AuthGate } from '@objectstack/core'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -68,9 +67,6 @@ import { import { type BetterAuthRouteOwnership, } from './better-auth-route-ownership.js'; -import { - applyPlatformAdminImpersonation, -} from './admin-impersonate-endpoint.js'; import { mintPrincipalSession, type PrincipalSessionAuthContext, @@ -89,13 +85,6 @@ import { applyUserUnban, SCIM_DEACTIVATION_BAN_REASON, } from './user-ban-write.js'; -import { - buildTwoFactorPluginSchema, - buildOauthProviderPluginSchema, - buildDeviceAuthorizationPluginSchema, - buildAdminPluginSchema, - buildPhoneNumberPluginSchema, -} from './auth-schema-config.js'; // [#25224 cut 1] The actor and organization guards; the methods of the same // names below hand each body the members it reads (`ActorGuardHost`). import { @@ -213,10 +202,11 @@ import { resolvePasswordHasher } from './auth-manager-password-hasher.js'; // after-hook (cut 12) and before-hook (cut 13) are now imported there, by the // two arrows that moved with it. import { createAuthInstance, type AuthInstanceHost } from './auth-manager-auth-instance.js'; -// [#25224 cut 15] The organization plugin's factory, the body of the arrow -// `buildPluginList` hands `addOptionalPlugin` for `organization`; that arrow -// hands it the members it reads (`OrganizationPluginHost`). -import { buildOrganizationPlugin, type OrganizationPluginHost } from './auth-manager-organization-plugin.js'; +// [#25224 cut 16] The body of `buildPluginList`; the method of the same name +// below hands it the members it reads (`PluginListHost`). The organization +// plugin's factory (cut 15) is now imported there, by the arrow that moved +// with it. +import { buildPluginList, type PluginListHost } from './auth-manager-plugin-list.js'; /** * Detect WebContainer (StackBlitz) environment. @@ -344,7 +334,7 @@ class SubjectErasureRollback extends Error { } } -function readBooleanEnv(name: string, legacyName?: string): boolean | undefined { +export function readBooleanEnv(name: string, legacyName?: string): boolean | undefined { const env = (globalThis as any)?.process?.env as Record | undefined; const raw = env?.[name] ?? (legacyName ? env?.[legacyName] : undefined); if (raw == null) return undefined; @@ -1450,7 +1440,7 @@ export const OAUTH_ORGANIZATION_CLAIM = 'organization_id'; * token carries no claim, which is the ruled outcome for a user with no * organization, never a guessed or default one. */ -function consentOrganizationOf(session: Record | null | undefined): string | undefined { +export function consentOrganizationOf(session: Record | null | undefined): string | undefined { const organizationId = session?.activeOrganizationId; return typeof organizationId === 'string' && organizationId ? organizationId : undefined; } @@ -1872,798 +1862,41 @@ export class AuthManager { * core auth stays up): organization, admin, phoneNumber, magicLink, * genericOAuth, jwt+oauthProvider, sso, scim, deviceAuthorization. */ - private async buildPluginList(): Promise { - this.degradedFeatures.clear(); - // Same lifecycle as degradedFeatures: an operator who fixes the cause and - // triggers a rebuild gets a fresh verdict, and a fresh log line if it is - // still broken. (The Ed25519 probe itself is NOT reset — it is a property - // of the runtime, which a rebuild cannot change.) - this.jwtSigningFailureReported = false; - const pluginConfig: Partial = this.config.plugins ?? {}; - const plugins: any[] = []; - - // Defaults — kept in sync with `AuthPluginConfigSchema` in - // @objectstack/spec/system/auth-config.zod.ts. The frontend AuthProvider - // (in @object-ui/app-shell) calls `/api/v1/auth/organization/list` on - // every load; making the org plugin opt-out (default true) avoids - // 404s and the noisy "Failed to load organizations" warning. - // - // `OS_OIDC_PROVIDER_ENABLED` lets operators flip the OIDC IdP on - // without re-deploying with a code change (mirrors the - // `OS_MULTI_ORG_ENABLED` / `OS_DISABLE_SIGNUP` pattern). When set, the - // env var WINS over the config-file setting so platform operators can - // override per-environment without touching the application bundle. - // Use the shared `readBooleanEnv` parser (same as OS_AUTH_TWO_FACTOR / - // OS_AUTH_PASSWORD_REJECT_BREACHED / OS_DISABLE_SIGNUP) so these accept the - // platform-standard truthy set (`true`/`1`/`yes`/`on`, case-insensitive) - // instead of only the literal string `'true'` — a repeated operator footgun - // (`OS_SSO_ENABLED=1` silently parsed as disabled). - // - // Precedence for `scim` / `sso` / `ssoDomainVerification` (#13439, - // maintainer ruling 2026-08-31): an EXPLICIT config value wins over the - // env var; the env var decides only where the config leaves the key unset - // (tri-state `z.boolean().optional()` in AuthPluginConfigSchema). This is - // deliberately the OPPOSITE of the env-wins order the OIDC/2FA/HIBP keys - // above keep: for these three, config is how the code constructing the - // AuthPlugin states a value the deployment env must not silently outrank - // (the cloud control plane's plan-derived `plugins.scim` is the known - // writer), while the operator per-environment override survives for every - // deployment that leaves the keys unset. - const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); - // Opt-in DNS domain-verification for external SSO providers (ADR-0135 D6). - // OFF by default → today's behavior exactly (register → login immediately). - // ON → @better-auth/sso mounts /sso/{request-domain-verification,verify-domain} - // AND enforces a HARD login gate: a provider whose domain is not DNS-verified - // rejects logins ("Provider domain has not been verified"). The two are - // coupled in @better-auth/sso (the endpoints only register when - // `domainVerification.enabled`), so this single flag governs both. Requires - // `OS_SSO_ENABLED` (the sso plugin must be loaded to honor it). - const ssoDomainVerifyFromEnv = readBooleanEnv('OS_SSO_DOMAIN_VERIFICATION'); - // @better-auth/scim's `active:false` → ban runs through the admin plugin, - // and org-scoped tokens need the organization plugin — so enabling SCIM - // forces `admin` on (organization already defaults on). See ADR-0134. - // An explicit `plugins.admin: false` beside effective SCIM is a - // contradiction refused below (#13816) — never silently honoured. - const scimEffective = resolveScimEnabled(pluginConfig); - // Re-checked here (the constructor already refused a conflicted BOOT - // config) because this is where the env var is actually read: an - // OS_SCIM_ENABLED that appears between construction and the lazy - // better-auth build would otherwise mount SCIM with its ADR-0134 - // deprovisioning path silently declined. - assertScimAdminCoherence(pluginConfig); - const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); - const hibpFromEnv = readBooleanEnv('OS_AUTH_PASSWORD_REJECT_BREACHED'); - const enabled = { - organization: pluginConfig.organization ?? true, - twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false, - passwordRejectBreached: hibpFromEnv ?? pluginConfig.passwordRejectBreached ?? false, - passkeys: pluginConfig.passkeys ?? false, - magicLink: pluginConfig.magicLink ?? false, - // Shared decision point (env → config → follows OS_MCP_SERVER_ENABLED), - // see resolveOidcProviderEnabled — keep auth-plugin.ts / features in sync. - oidcProvider: resolveOidcProviderEnabled(pluginConfig), - deviceAuthorization: pluginConfig.deviceAuthorization ?? false, - admin: pluginConfig.admin ?? scimEffective, - // #2766 V1.5 — phone+password sign-in. Opt-in; OTP flows stay off until - // SMS infrastructure exists (tracked separately). - phoneNumber: (pluginConfig as any).phoneNumber ?? false, - sso: pluginConfig.sso ?? ssoFromEnv ?? false, - ssoDomainVerification: pluginConfig.ssoDomainVerification ?? ssoDomainVerifyFromEnv ?? false, - scim: scimEffective, + private buildPluginList(): Promise { + // [#25224 cut 16] The body moved to `auth-manager-plugin-list.ts` (see `PluginListHost`). + const readConfig = () => this.config; + const readDegradedFeatures = () => this.degradedFeatures; + const readJwtSigningFailureReported = () => this.jwtSigningFailureReported; + const writeJwtSigningFailureReported = (v: AuthManager['jwtSigningFailureReported']) => { this.jwtSigningFailureReported = v; }; + const readOrgRolesMap = () => this.orgRolesMap; + const writeOrgRolesMap = (v: AuthManager['orgRolesMap']) => { this.orgRolesMap = v; }; + const host: PluginListHost = { + addOptionalPlugin: (...a) => this.addOptionalPlugin(...a), + buildJwtPlugin: (...a) => this.buildJwtPlugin(...a), + computeAuthGate: (...a) => this.computeAuthGate(...a), + get config() { return readConfig(); }, + get degradedFeatures() { return readDegradedFeatures(); }, + deliverPhoneOtp: (...a) => this.deliverPhoneOtp(...a), + emailLocaleArg: (...a) => this.emailLocaleArg(...a), + enforceSessionControls: (...a) => this.enforceSessionControls(...a), + getAppName: (...a) => this.getAppName(...a), + getConsolePageUrl: (...a) => this.getConsolePageUrl(...a), + getDataEngine: (...a) => this.getDataEngine(...a), + getEmailService: (...a) => this.getEmailService(...a), + getMcpResourceUrl: (...a) => this.getMcpResourceUrl(...a), + isPlatformAdminUserId: (...a) => this.isPlatformAdminUserId(...a), + get jwtSigningFailureReported() { return readJwtSigningFailureReported(); }, + set jwtSigningFailureReported(v) { writeJwtSigningFailureReported(v); }, + multiOrgPostureEffective: (...a) => this.multiOrgPostureEffective(...a), + get orgRolesMap() { return readOrgRolesMap(); }, + set orgRolesMap(v) { writeOrgRolesMap(v); }, + reconcileScimUserLifecycle: (...a) => this.reconcileScimUserLifecycle(...a), + resolveAuthSecret: (...a) => this.resolveAuthSecret(...a), + resolveMembershipRole: (...a) => this.resolveMembershipRole(...a), + resolveTwoFactorAccountLockout: (...a) => this.resolveTwoFactorAccountLockout(...a), + storedRecipientLocale: (...a) => this.storedRecipientLocale(...a), }; - - // bearer() — ALWAYS enabled. - // - // Enables token-based authentication for cross-origin and mobile clients - // where third-party cookies are blocked (e.g. Safari ITP, Chrome CHIPS, - // native apps). The plugin: - // • Accepts `Authorization: Bearer ` on incoming requests and - // transparently resolves the session as if a cookie had been sent. - // • Emits a `set-auth-token` response header on sign-in / session-refresh - // that the client can store (e.g. in `localStorage`) and replay on - // subsequent requests. - // - // This mirrors how Salesforce, Notion, Supabase and first-party mobile - // SDKs handle auth. Cookie-based auth remains available for same-origin - // browser deployments; bearer is additive, not a replacement. - const { bearer } = await import('better-auth/plugins/bearer'); - plugins.push(bearer()); - - if (enabled.organization) { - // [#25224 cut 15] The body of this factory moved to - // `auth-manager-organization-plugin.ts`; this arrow hands it the members - // it reads (`OrganizationPluginHost`). - await this.addOptionalPlugin(plugins, 'organization', () => { - const readConfig = () => this.config; - const readOrgRolesMap = () => this.orgRolesMap; - const writeOrgRolesMap = (v: AuthManager['orgRolesMap']) => { this.orgRolesMap = v; }; - const host: OrganizationPluginHost = { - get config() { return readConfig(); }, - emailLocaleArg: (...a) => this.emailLocaleArg(...a), - getAppName: (...a) => this.getAppName(...a), - getConsolePageUrl: (...a) => this.getConsolePageUrl(...a), - getDataEngine: (...a) => this.getDataEngine(...a), - getEmailService: (...a) => this.getEmailService(...a), - multiOrgPostureEffective: (...a) => this.multiOrgPostureEffective(...a), - get orgRolesMap() { return readOrgRolesMap(); }, - set orgRolesMap(v) { writeOrgRolesMap(v); }, - resolveMembershipRole: (...a) => this.resolveMembershipRole(...a), - storedRecipientLocale: (...a) => this.storedRecipientLocale(...a), - }; - return buildOrganizationPlugin.call(host); - }); - } - - if (enabled.twoFactor) { - const { twoFactor } = await import('better-auth/plugins/two-factor'); - plugins.push(twoFactor({ - schema: buildTwoFactorPluginSchema(), - // ADR-0069 D2 (#3690) — the operator's lockout policy governs BOTH - // stages of sign-in, not just the password one. - accountLockout: this.resolveTwoFactorAccountLockout(), - })); - } - - // Breached-password rejection (ADR-0069 D1). Native, stateless: a - // k-anonymity range check against Have I Been Pwned on better-auth's - // password-mutating endpoints (sign-up / change / reset — the plugin's - // defaults). The plaintext password is never sent; only the first 5 SHA-1 - // hex chars leave the process. Rejects with PASSWORD_COMPROMISED. - if (enabled.passwordRejectBreached) { - const { haveIBeenPwned } = await import('better-auth/plugins/haveibeenpwned'); - plugins.push(haveIBeenPwned({ - customPasswordCompromisedMessage: - 'This password has appeared in a known data breach. Please choose a different one.', - })); - } - - if (enabled.admin) { - await this.addOptionalPlugin(plugins, 'admin', async () => { - const { admin } = await import('better-auth/plugins/admin'); - // Platform admin: ban/unban, set-password, impersonate, set-role. - // Schema mapping ensures the plugin's added user/session columns - // match ObjectStack's snake_case conventions (ban_reason, - // ban_expires, impersonated_by). `role` and `banned` are already - // snake_case-compatible. - const adminPlugin: any = admin({ - schema: buildAdminPluginSchema(), - }); - - // ADR-0068 D2 — re-authorize `/admin/impersonate-user` on ObjectStack's - // platform-admin predicate, IN PLACE on this plugin's own endpoints - // record. See `admin-impersonate-endpoint.ts` for the measurement that - // picked this shape over a second plugin (which boots and serves, but - // makes `checkEndpointConflicts` log an error on every start) and over - // a raw Hono mount (forbidden: hand-rolled signed cookies, and it would - // silently detach the #8243 rotation hook keyed on this path). - // - // Every OTHER better-auth-native `/admin/*` route still gates on the - // legacy scalar and still refuses platform admins — that is the parent - // card's remaining surface, deliberately untouched here. (A caller the - // ADR-0069 policy holds is refused on those ahead of the vendor, by the - // global before-hook — `VENDOR_ROLE_SCALAR_ADMIN_PATHS`.) - // - // ADR-0069 D3 — the same endpoint refuses a caller the policy holds, - // asked of the session gate's own posture carrier (`computeAuthGate`, - // the first entry of `resolveUserAuthGates`); see the endpoint module. - const rewired = await applyPlatformAdminImpersonation( - adminPlugin, - (userId: string) => this.isPlatformAdminUserId(userId), - (userId: string, organizationId: string | undefined) => - this.computeAuthGate(userId, organizationId, false), - ); - if (!rewired) { - // The vendor renamed or dropped the endpoint. Say so loudly: the - // route then falls back to the vendor's own handler, which refuses - // every platform admin — a broken button, not an open door. - console.error( - '[AuthManager] better-auth\'s admin plugin no longer exposes a ' + - '`impersonateUser` endpoint at /admin/impersonate-user, so the ' + - 'ADR-0068 platform-admin authorization could NOT be applied. ' + - 'Impersonation will refuse every ObjectStack platform admin until ' + - 'admin-impersonate-endpoint.ts is updated for the new vendor shape.', - ); - } - return adminPlugin; - }); - } - - if (enabled.phoneNumber) { - await this.addOptionalPlugin(plugins, 'phoneNumber', async () => { - const { phoneNumber } = await import('better-auth/plugins/phone-number'); - // #2766 V1.5 wired phone+password sign-in; #2780 opens the OTP surface - // (`/phone-number/send-otp` + `/verify`, `/request-password-reset` + - // `/reset-password`) whenever an SMS service is available — resolved - // lazily per send so the plugin list stays stable while the capability - // upgrades at kernel:ready. Without one, sendOTP still fails loudly - // (400 SMS_SERVICE_REQUIRED) instead of silently logging. signUpOnVerification - // stays deliberately NOT configured — phone-only accounts are created - // by the admin create-user/import routes with a placeholder email - // (see placeholder-email.ts), never by OTP self-signup. - const otpCfg = this.config.phoneOtp ?? {}; - return phoneNumber({ - schema: buildPhoneNumberPluginSchema(), - // Wrong-code attempts before the stored OTP is invalidated. Explicit - // (even though 3 is the better-auth default) — #2780 names it a - // security requirement, so it must not drift with a dependency bump. - allowedAttempts: otpCfg.allowedAttempts ?? 3, - ...(otpCfg.expiresIn != null ? { expiresIn: otpCfg.expiresIn } : {}), - ...(otpCfg.otpLength != null ? { otpLength: otpCfg.otpLength } : {}), - // Reject garbage before an SMS is paid for (same shape rule as the - // admin endpoints' normalizePhoneNumber). - phoneNumberValidator: (phone: string) => - /^\+?[0-9]{6,15}$/.test(String(phone ?? '').replace(/[\s\-().]/g, '')), - // Sign-in / verify OTP. Throws surface to the endpoint (better-auth - // awaits this callback on /phone-number/send-otp), so the guard's - // TOO_MANY_REQUESTS becomes an honest 429 to the client. - sendOTP: async ({ phoneNumber: phone, code }) => { - await this.deliverPhoneOtp(phone, code); - }, - // Self-service password reset OTP (`/phone-number/request-password- - // reset`). better-auth invokes this via runInBackgroundOrAwait — - // errors are logged, the route still answers {status:true} — so a - // throw here can neither 500 the request nor leak whether the number - // is registered. - sendPasswordResetOTP: async ({ phoneNumber: phone, code }) => { - await this.deliverPhoneOtp(phone, code); - }, - }); - }); - } - - if (enabled.magicLink) { - await this.addOptionalPlugin(plugins, 'magicLink', async () => { - const { magicLink } = await import('better-auth/plugins/magic-link'); - // magic-link reuses the `verification` table — no extra schema mapping needed. - // - // The link's lifetime, in seconds, and the ONE source for it: the mount - // states it and the mail's `expiresInMinutes` is derived from it, so the - // copy says what the link does. 300 s is the lifetime the link already - // had through the vendor default (`opts.expiresIn || 300`, better-auth - // 1.7.12 `plugins/magic-link/index.mjs`), now written down so a - // dependency bump cannot move it under the mail. ⛔ Lengthening it - // relaxes a sign-in credential's validity — a security-boundary change - // for the maintainer, not a copy fix. - const magicLinkExpiresInSec = 5 * 60; - return magicLink({ - expiresIn: magicLinkExpiresInSec, - // #14319 — this one is called with the endpoint CTX, not a Request - // (measured; magic-link/index.mjs `sendMagicLink({...}, ctx)`). - sendMagicLink: async ({ email: recipientEmail, url, token }, ctx?: unknown) => { - // #2766 V1.5 — placeholder addresses are never real recipients. - if (isPlaceholderEmail(recipientEmail)) { - throw new Error( - `Magic-link email refused: ${recipientEmail} is a placeholder address (PLACEHOLDER_EMAIL).`, - ); - } - const emailService = this.getEmailService(); - if (!emailService) { - // #2766 — a magic link IS a session credential; only print it in - // dev, never in production logs. - const dev = (globalThis as any)?.process?.env?.NODE_ENV !== 'production'; - console.warn( - `[AuthManager] Magic-link requested for ${recipientEmail} but no email service is wired.` + - (dev ? ` URL: ${url}` : ' (link suppressed outside dev)'), - ); - return; - } - // #15106 — the recipient's OWN stored language, the last auth mail - // to reach the top rung of the #14788 option-D ladder. Same rung and - // same helper as the other four sends; only the predicate differs, - // because this callback is handed an address rather than a user row - // — the #14641 invitation shape, and for the same reason it has two - // branches: - // - // 1. the address HAS a `sys_user` row — the ordinary case, since a - // magic link is how an existing account signs in without a - // password → their own `locale`; - // 2. NO row — magic link doubles as sign-up (measured in the - // installed 1.7.x: `/sign-in/magic-link` sends WITHOUT checking - // for an account, and `/magic-link/verify` creates one unless - // `disableSignUp`), so an unknown address is legitimate here and - // its language is genuinely unknown → the rungs below stand. - // - // ⚠️ Lowercased, unlike the invitation predicate, and the difference - // is measured rather than stylistic: better-auth lowercases the - // invitee address on the invite route, but `signInMagicLinkBodySchema` - // applies no such transform, so what arrives here is whatever the - // caller typed. `internalAdapter.findUserByEmail` — the lookup - // `/magic-link/verify` resolves this very link with — matches on - // `email.toLowerCase()`. Reading the column on the same spelling - // keeps the row the mail is ABOUT identical to the row the link will - // sign into; an address that resolves nothing lands on the rungs - // below, which is the documented floor rather than a failure. - // - // ⛔ The request rung is NOT removed: ruling D inserts the column - // ABOVE `Accept-Language`, it does not replace it. A magic link is - // requested by its own recipient, so `ctx` here carries the - // recipient's own header and stays a legitimate second rung for an - // account that has stated no language. - const storedLocale = await this.storedRecipientLocale({ - email: recipientEmail.toLowerCase(), - }); - try { - await emailService.sendTemplate({ - template: 'auth.magic_link', - to: recipientEmail, - ...this.emailLocaleArg(ctx, storedLocale), - data: { - magicLinkUrl: url, - token, - expiresInMinutes: Math.round(magicLinkExpiresInSec / 60), - appName: this.getAppName(), - }, - // [ADR-0131 D7, Q2 A] A sign-in link for a person: identity mail. - identityMail: true, - }); - } catch (err: any) { - console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`); - throw err; - } - }, - }); - }); - } - - // OIDC / Generic OAuth2 providers (enterprise SSO via genericOAuth plugin) - if (this.config.oidcProviders?.length) { - const oidcProviders = this.config.oidcProviders; - await this.addOptionalPlugin(plugins, 'genericOAuth', async () => { - const { genericOAuth } = await import('better-auth/plugins/generic-oauth'); - return genericOAuth({ - config: oidcProviders.map(p => ({ - providerId: p.providerId, - ...(p.discoveryUrl ? { discoveryUrl: p.discoveryUrl } : {}), - ...(p.issuer ? { issuer: p.issuer } : {}), - ...(p.authorizationUrl ? { authorizationUrl: p.authorizationUrl } : {}), - ...(p.tokenUrl ? { tokenUrl: p.tokenUrl } : {}), - ...(p.userInfoUrl ? { userInfoUrl: p.userInfoUrl } : {}), - clientId: p.clientId, - clientSecret: p.clientSecret, - ...(p.scopes ? { scopes: p.scopes } : {}), - ...(p.pkce != null ? { pkce: p.pkce } : {}), - })), - }); - }); - } - - // OAuth/OIDC Provider — turn this server into an OpenID Connect Identity - // Provider so external apps can SSO via ObjectStack. Adds the - // `/oauth2/{authorize,token,userinfo,register,consent,endsession}` and - // `/.well-known/openid-configuration` endpoints under the auth route. - // - // Migrated from the deprecated `better-auth/plugins/oidc-provider` to the - // standalone `@better-auth/oauth-provider` package. The new plugin uses - // `oauthClient`, `oauthAccessToken`, `oauthRefreshToken`, and `oauthConsent` - // models — see `buildOauthProviderPluginSchema()` for the snake_case - // mappings to ObjectStack's `sys_oauth_*` tables. - if (enabled.oidcProvider) { - // jwt + oauthProvider are one atomic unit: on failure neither lands. - // This is the plugin that broke every fresh 15.1.0 project (modelName - // TypeError from a 1.6/1.7 version mix) — see addOptionalPlugin. - await this.addOptionalPlugin(plugins, 'oidcProvider', async () => { - // The new @better-auth/oauth-provider package requires the `jwt` - // plugin (used to sign id_tokens / JWT access tokens). Register it - // automatically — it is otherwise an internal implementation detail - // and forcing every consumer to opt in would be poor DX. - const { jwt } = await import('better-auth/plugins'); - const jwtPlugin = await this.buildJwtPlugin(jwt); - - const { oauthProvider } = await import('@better-auth/oauth-provider'); - const dcr = resolveDcrEnabled(pluginConfig); - return [jwtPlugin, oauthProvider({ - // Console SPA renders both pages (replaces the legacy Account SPA at - // /_account). Override `uiBasePath` in AuthConfig if Console is - // mounted elsewhere. - loginPage: this.getConsolePageUrl('/login'), - consentPage: this.getConsolePageUrl('/oauth/consent'), - schema: buildOauthProviderPluginSchema(), - // ⛔ No `silenceWarnings` here. It was added for the #3420 double - // print of oauth-provider's "Please ensure /.well-known/… exists" - // notice — a false positive, because registerOidcDiscoveryRoutes - // mounts those documents at the issuer ROOT where RFC 8414 §3 requires - // them. The pinned 1.7.2 emits no such notice: neither the option name - // nor the `oauthAuthServerConfig` key nor the notice text occurs - // anywhere in `@better-auth/oauth-provider` or `better-auth`, so the - // option silenced nothing and was the same dead-option shape as the - // `validAudiences` defect below. If a future bump reintroduces the - // notice, re-add the silencer with a fresh reading — do NOT restore it - // on the strength of this comment. - // ── MCP OAuth track (#2698) ──────────────────────────────── - // Coarse tool-family scopes for the platform's own MCP endpoint, - // advertised alongside the standard OIDC scopes. Names are - // single-sourced in @objectstack/spec so AS / resource server / - // tool layer cannot drift. - scopes: ['openid', 'profile', 'email', 'offline_access', ...MCP_OAUTH_SCOPES], - // MCP clients bind tokens to the resource via RFC 8707 - // (`resource=`). In @better-auth/oauth-provider 1.7.2 a - // requested `resource` is resolved from the `oauthResource` table - // (`sys_oauth_resource`) — a miss is refused at /oauth2/authorize with - // `invalid_target: requested resource is not configured` — and - // `enforcePerClientResources` defaults to TRUE, so the client must - // additionally be linked in `oauthClientResource` - // (`sys_oauth_client_resource`). Both rows have to exist before the - // first Connect, so both are declared here: - // - // • `resources` seeds the sys_oauth_resource row from the plugin's - // own `init` (idempotent, `resourceSeedMode: "insertOnly"` by - // default, so an admin's later CRUD edits are never reverted); - // • `clientRegistrationDefaultResources` links every newly - // registered client to it inside the DCR transaction — a client - // that registers anonymously one second before the login cannot - // be linked by an admin in between. - // - // ⛔ `enforcePerClientResources` is deliberately NOT passed: the - // per-client linkage check stays at its `true` default. The fix makes - // the link happen; it does not switch the check off. A client with no - // link row is still refused, and - // auth-manager.mcp-oauth-resource.test.ts asserts exactly that. - // - // ⛔ Do not reintroduce `validAudiences`: 1.7.2 reads no such option - // (0 occurrences in its dist), and audience validation now runs - // through the resource table instead. A field that is passed and read - // by nobody looks like configuration and enforces nothing — that is - // how this defect survived a version bump. - resources: [this.getMcpResourceUrl()], - clientRegistrationDefaultResources: [this.getMcpResourceUrl()], - // RFC 7591 Dynamic Client Registration. `allowUnauthenticated…` is - // required: MCP clients register BEFORE any user is logged in (the - // whole point of the self-serve flow). Registration is rate-limited - // by the plugin, clients get only the scopes advertised above, and - // every token still requires an interactive PKCE login + consent — - // an anonymous registration mints no authority by itself. - allowDynamicClientRegistration: dcr, - allowUnauthenticatedClientRegistration: dcr, - // ── Native MCP clients that omit `application_type` (#25118, ruling 6329352251, A1) ── - // Local MCP clients (Claude Code, Cursor, VS Code, Codex CLI) register - // over DCR with a loopback `http` or a custom-scheme redirect, as RFC - // 8252 §7.3 and the MCP authorization spec have native clients do, and - // send no `application_type`. The provider's default fills a missing - // type with `web` (OIDC Registration §2), whose redirect rule refuses - // both forms, so every such client was refused before its browser - // opened. - // - // `'infer'` is the provider's own classifier; there is no second rule - // here. A dynamic registration that omits the type becomes `native` - // when ANY redirect uses a non-http(s) scheme or `http` on exactly - // `localhost`, `127.0.0.1` or `[::1]`, and each redirect is then held to - // the native rule; otherwise it stays `web`. A type the client sends is - // never overridden. It acts on `/oauth2/register` only (the Setup - // form's managed `create-client` door is unaffected) and only while - // `dcr` is on: with DCR off the provider refuses every registration. - // - // The measured boundary, pinned on the real provider in - // auth-manager.mcp-oauth-dcr-infer.test.ts: - // • a public client with a loopback or custom-scheme redirect → `native`; - // • `https://evil.example/cb` alone stays `web`; `http://evil.example/cb`, - // `http://localhost.evil.example`, and an explicit `web` with a - // loopback redirect stay refused; - // • NOT "public clients only": the classifier reads redirect URIs and - // never `token_endpoint_auth_method`, so a CONFIDENTIAL registration - // (`client_secret_basic`, or the method omitted, which the provider - // fills as `client_secret_basic`) with a loopback or custom-scheme - // redirect and no type also registers as `native` and is issued a - // `client_secret`. That body was already accepted with - // `application_type: 'native'` spelled out, so no registrable client - // shape is added; only the meaning of an omitted field changes - // (RFC 8252 §8.4 allows a per-instance secret provisioned by DCR); - // • PKCE stays required at /oauth2/authorize for every one of these - // clients: unconditionally for a public client, and for a - // confidential DCR client because `clientRegistrationRequirePKCE` - // keeps its `true` default. ⛔ Do not pass it as `false`. - // - // ⛔ No hook on `/oauth2/register` and no second classification rule - // beside this one: ruling A1 declined both. - clientRegistrationDefaultApplicationType: 'infer', - // ── The organization an OAuth consent binds (ruling 6232069920, A) ── - // At consent the consenting session's active organization becomes the - // consent's `reference_id`, and the installed provider carries that - // value onto the authorization code, the refresh family it starts, and - // every access token minted from either, where `customAccessTokenClaims` - // emits it as `organization_id` (`verifyMcpAccessToken` reads it back). - // - // ⛔ The value is the SESSION's, never the client's: the hook reads the - // session the provider hands it and nothing else. The authorize query, - // the consent body and the client's metadata never reach it, so no - // request parameter can choose the organization a token acts in. - // - // ⛔ No session organization ⇒ no reference and no claim. Never a - // guessed or default one: the principal then resolves with global - // grants only. - // - // Existing consents re-confirm once, by the provider's own lookup: it - // finds a consent by (client, user, reference) whenever this hook - // answers a reference, so a consent stored before this binding (its - // `reference_id` is null) no longer satisfies an authorize made in an - // organization, and the user sees the consent page again. Until then an - // old refresh family keeps minting tokens with no claim, because a - // refresh carries the family's own reference, never the session's. - // - // `postLogin` is declared as one object, so its two other members are - // required beside the hook. `shouldRedirect` answers false: the - // organization is the one the session already has, not a choice made - // on an extra page. `page` is read only when `shouldRedirect` answers - // true, which it never does here; it names the consent page so that it - // could only ever point at a page that exists. - postLogin: { - page: this.getConsolePageUrl('/oauth/consent'), - shouldRedirect: () => false, - consentReferenceId: ({ session }) => consentOrganizationOf(session), - }, - // `organization_id` is not in the provider's reserved access-token - // claim set, which it strips at issuance. This is the claim the - // `IAuthService.verifyMcpAccessToken` contract names. - customAccessTokenClaims: ({ referenceId }) => - referenceId ? { [OAUTH_ORGANIZATION_CLAIM]: referenceId } : {}, - })]; - }); - } - - // External SSO (OIDC / SAML) relying-party — lets this environment federate - // login to a customer's own IdP (Okta / Entra / Google …). Per-env, runtime- - // registered providers live in `sys_sso_provider` (ADR-0135 D6: the OPEN SSO - // mechanism — cloud-free for self-host). Endpoints mount under - // /api/v1/auth/sso/{register,providers,delete-provider,callback,…}. - // - // Toggle with `OS_SSO_ENABLED` (mirrors `OS_OIDC_PROVIDER_ENABLED`). - if (enabled.sso) { - await this.addOptionalPlugin(plugins, 'sso', async () => { - const { sso } = await import('@better-auth/sso'); - // NOTE: the `ssoProvider` model is bridged to `sys_sso_provider` by the - // better-auth adapter / a global model map, not per-plugin here (see the - // `ssoProvider` NOTE in auth-schema-config.ts). - // - // That bridge dates from 1.6.20, where @better-auth/sso hardcoded the - // model and read no `schema` option. Re-measured against the installed - // 1.7.1 (`node_modules/@better-auth/sso/dist`) on 2026-08-19 — the pin - // moved off 1.7.0-rc.2, so the previous stamp here had itself expired - // (#8224): still no longer true — `SSOOptions.schema.ssoProvider` exists - // (index-CZytzKv6.d.mts) and the runtime honours `modelName` plus a - // per-field `fieldName` map (index.mjs, the plugin's `schema:` block). The - // adapter-level bridge is kept as-is here because it is what the rest of - // the auth stack is wired to; whether to move it onto the plugin option is - // a separate change, not a silent one. Only the mapping surface below was - // re-verified in depth — see register-sso-provider.ts for the - // `oidcConfig.mapping` strict-object findings. - // - // `organizationProvisioning.defaultRole` (cloud ADR-0024 V1): a first-time - // federated login is JIT-provisioned into the user's domain-matched org - // with this role, so a member who arrives via an external IdP lands with - // an explicit default role (belt-and-suspenders over SecurityPlugin's - // `member_default` fallback, which already grants baseline access to any - // authenticated user). Requires the `organization` plugin — on by default. - // `domainVerification.enabled` (ADR-0135 D6, opt-in via OS_SSO_DOMAIN_VERIFICATION): - // when on, @better-auth/sso mounts /sso/request-domain-verification + - // /sso/verify-domain (DNS TXT proof-of-ownership) AND enforces that an - // external IdP's email domain be DNS-verified before it may complete a - // login — preventing an org admin from registering a provider for a domain - // they don't control. Off by default to preserve the register→login flow. - return sso({ - organizationProvisioning: { defaultRole: 'member' }, - ...(enabled.ssoDomainVerification ? { domainVerification: { enabled: true } } : {}), - }); - }); - } - - // External SCIM 2.0 Service Provider (@better-auth/scim, MIT) — lets an - // external IdP (Okta / Entra) auto-provision / deprovision THIS env's users - // (the paid Identity lifecycle, cloud ADR-0071). The env is the SCIM Service - // Provider; SCIM 2.0 endpoints mount under /api/v1/auth/scim/v2/{Users,…}. - // `active:false` → ban + session revoke (needs the admin plugin, forced on - // above). This plugin still accepts no `schema` option (`SCIMOptions` - // declares no `schema` / `modelName` / `fields` member on the installed - // 1.7.1), so its seven models are bridged to the `sys_scim_*` platform - // objects via AUTH_MODEL_TO_PROTOCOL. Toggle with `OS_SCIM_ENABLED`. - // - // Connections are RUNTIME DATA, not boot config (#3653): the stable - // constructor's three-way requirement (static `connections` | bearer - // verifier | managed catalog) is satisfied with an application-owned - // `verifyBearerToken` that resolves the connection from a - // `sys_scim_connection_credential` row at request time. ObjectStack owns - // the whole credential lifecycle — mint / store / verify — in - // `scim-connection-service.ts`; only a keyed one-way digest is ever - // persisted (pinned by `credential-at-rest-posture.test.ts`). The rc.1 - // `storeSCIMToken` / `/scim/generate-token` surface no longer exists on - // stable, and the upstream `managedConnections` catalog is deliberately - // NOT adopted (maintainer ruling 2026-08-25). - if (enabled.scim) { - await this.addOptionalPlugin(plugins, 'scim', async () => { - const { scim } = await import('@better-auth/scim'); - const { verifyScimBearerToken } = await import('./scim-connection-service.js'); - const secret = this.resolveAuthSecret(); - return scim({ - connections: [], - authentication: { - verifyBearerToken: async (input) => { - // ⛔ No `scimRequestScope.enterWith(...)` here. The SCIM request - // scope that makes the adapter open a REAL engine transaction is - // opened by `handleRequest` with `run(...)` around the whole - // request (see `SCIM_PROTOCOL_PATH_PREFIX`). It used to be - // stamped from this callback and never reached the writes: an - // `enterWith` marks only the async resource it runs in and that - // resource's descendants, and the vendor resumes the endpoint - // handler from a continuation captured BEFORE this verifier ran - // (measured on 1.7.2 — zero engine transactions across a SCIM - // POST + PATCH; pinned by `scim-transaction-scope.test.ts`). - const engine = this.config.dataEngine; - if (!engine) return null; // no store to verify against — fail closed - return verifyScimBearerToken(engine as never, secret, input.token); - }, - }, - // [#14360] The host half of `active`: stable @better-auth/scim - // writes no `banned` itself any more (the 1.6.x coupling left the - // package in 1.7.0) — it hands the aggregate lifecycle state to - // this callback inside the SCIM transaction and only revokes - // sessions. Routed to the platform's own ban write; the break-glass - // last-administrator guard judges it at the engine. See - // `reconcileScimUserLifecycle` for the contract and the measurement. - identity: { - reconcileUser: (state, context) => this.reconcileScimUserLifecycle(state, context), - }, - }); - }); - } - - // Device Authorization Grant (RFC 8628) — for CLI / TV-style devices. - // Exposes the standard `/device/{code,token,approve,deny}` endpoints - // and persists pending requests in `sys_device_code`. - // - // The verification URI points at the account portal page that lets a - // signed-in user approve or deny a pending CLI login. The page reads - // the `user_code` query parameter that better-auth appends to - // `verification_uri_complete`. - if (enabled.deviceAuthorization) { - await this.addOptionalPlugin(plugins, 'deviceAuthorization', async () => { - const { deviceAuthorization } = await import('better-auth/plugins/device-authorization'); - return deviceAuthorization({ - verificationUri: this.getConsolePageUrl('/auth/device'), - schema: buildDeviceAuthorizationPluginSchema(), - }); - }); - } - - // customSession() — augments the session payload with the canonical - // `positions: string[]` array (ADR-0068 D1/D2, renamed ADR-0090 D3) and its - // derived `isPlatformAdmin` alias. - // - // [#15136] `positions[]` is the SECURITY axis — the same set - // `/auth/me/permissions` reports and every server-side evaluator resolves: - // the active organization's membership mapped to canonical - // `org_owner`/`org_admin`/`org_member`, the ADR-0057 D4 `sys_user_position` - // assignments, the ADR-0090 D5 `everyone` anchor, and `platform_admin`. It - // is not derived here; it is asked of `resolveUserAuthzGrants`, the ONE - // authority, for the reason spelled out at the call site below. - // - // ⛔ The better-auth `user.role` scalar is NOT a source of it. It used to be - // (split on commas and unioned in), which put auth-layer role names on an - // array whose declared meaning — `EvalUserSchema`, "built-in identity names - // + position names" — is the security axis, and left the real position names - // off it entirely. - // - // IMPORTANT: `user.role` is NOT overwritten — consumers must gate on - // `positions[]` / `isPlatformAdmin` (e.g. via objectui's - // `useWorkspaceAdminStatus`), never on `user.role === 'admin'`. A consumer - // that genuinely wants the better-auth role reads `user.role`, which stays - // on the payload verbatim; the raw membership role stays on the organization - // plugin's `member` payload. - const dataEngine = this.config.dataEngine; - if (dataEngine) { - const { customSession } = await import('better-auth/plugins/custom-session'); - plugins.push(customSession(async ({ user, session }) => { - if (!user?.id) return { user, session }; - - // ADR-0068 D1/D2 (renamed ADR-0090 D3) — emit ONE canonical - // positions[] (identity names + position names), with NO singular - // overwrite. isPlatformAdmin is a DERIVED alias of - // `'platform_admin' in positions`, retained for back-compat clients. - // [#10348] Asked through the ONE authority, exactly as `/sso/register` - // and `/admin/impersonate-user` ask it — so the session payload can no - // longer disagree with the gates about who a platform admin is. - // - // [#15136 — maintainer ruling 2026-09-05, option A] ONE name, ONE - // meaning: `positions[]` is the SECURITY axis on every surface. - // - // This used to be a hand-rolled union — the better-auth `sys_user.role` - // scalar split on commas, plus the active membership mapped to `org_*`, - // plus `platform_admin`. It read NOTHING from `sys_user_position`, the - // ADR-0057 D4 table that is the source of truth for custom positions, so - // a user genuinely holding a business position got a payload without it - // and every client-side gate narrowed by that position answered FALSE — - // silently, because the root and the key were both bound and CEL raised - // nothing. `EvalUserSchema` had declared the opposite all along - // ("built-in identity names + position names", "evaluates identically - // wherever it is written"); the Console binds this array straight through - // as `current_user`, so the payload WAS the contract violation. - // - // The derivation is not repaired here — it is DELETED and asked of the - // authority instead. `resolve-authz-context.ts` states that every entry - // point must resolve authorization through it and never re-read the - // `sys_*` grant tables itself; this callback was doing exactly what that - // forbids, which is how it drifted out of agreement with - // `/auth/me/permissions` (served from the same `grants.positions`) and - // with every server-side evaluator (`ExecutionContext.positions`) in the - // first place. The same move `isPlatformAdminUserId` made at #10348. - // - // Scoped to the session's ACTIVE organization, so a position held in one - // organization does not answer while the caller operates in another — - // the resolver's own rule, now applied to the payload too. - // - // `isPlatformAdmin` comes from the posture RUNG on the same envelope, - // never from the array. ⛔ `positions.includes('platform_admin')` is the - // form `resolve-authz-context.ts` explicitly forbids, because an - // ADR-0057 D4 `sys_user_position` row MAY SPELL THAT VERY NAME and a - // platform-RBAC assignment is not the ADR-0068 D2 capability grant. - // Under ruling A that stopped being theoretical: moving `positions` to - // the security axis moved the string `platform_admin` into a space a - // tenant admin can WRITE (`sys_user_position` is `apiEnabled`), so an - // array read here would have let a tenant mint platform standing and - // pass the `/admin/*` mount gate. `grants.posture === 'PLATFORM_ADMIN'` - // is byte-for-byte what `hasPlatformAdminStanding` returns, so the - // payload, that predicate and `judgePlatformAdmin` cannot disagree. - // ADR-0068 D2 defines the alias as `'platform_admin' in roles`; that - // wording predates D4 rows being able to spell built-in names, and - // core's later ⛔ is the specific rule. - // - // Fail CLOSED on an unreadable grant store, matching what both halves of - // the old derivation already did (`isPlatformAdminUserId` returns false - // on any lookup error; `activeOrgRoles` caught to `[]`). Warned rather - // than swallowed — an empty `positions[]` hides UI, and this card is - // about exactly that going unannounced. - // - // The seed email is the session's own, spelled as `resolveAuthzContext` - // spells it for this session (`String(user.email)` when present), so an - // in-process session read that `resolveAuthzContext` makes asks for - // EXACTLY the resolution its own step 2 asks for — and core's - // request-scoped memo serves that step this resolution instead of - // issuing every grant read twice per request. It shapes nothing read - // here: `grants.email` is the only output it reaches, and the - // platform-admin config anchor compares the STORED `sys_user.email`, - // never a seed (core §6b-config). - let positions: string[] = []; - let platformAdmin = false; - try { - const grants = await resolveUserAuthzGrants(dataEngine as any, user.id, { - tenantId: (session as any)?.activeOrganizationId ?? undefined, - seedEmail: user.email ? String(user.email) : undefined, - }); - positions = grants.positions; - platformAdmin = grants.posture === 'PLATFORM_ADMIN'; - } catch (err: any) { - console.warn( - '[auth] could not resolve authorization grants for the session payload; ' - + 'positions[] is empty and position-gated UI will be hidden:', - err?.message ?? String(err), - ); - } - - // ADR-0069 — authentication-policy gate posture (password expiry, - // enforced MFA). Computed only when a gate feature is enabled (else - // zero extra reads on the hot path); surfaced as `user.authGate` for - // the transport seams to enforce. See computeAuthGate(). - // ADR-0069 D4 — session controls (idle / absolute). Best-effort; - // revokes the session in place when exceeded (next request → 401). - await this.enforceSessionControls((session as any)?.id, (session as any)?.createdAt); - - // An impersonation session carries the administrator who started it - // (`impersonatedBy`, written by the admin plugin's impersonation and - // stored as `sys_session.impersonated_by`); which postures hold the - // session depends on it — see computeAuthGate(). - const impersonatedBy = (session as any)?.impersonatedBy; - const authGate = await this.computeAuthGate( - user.id, - (session as any)?.activeOrganizationId, - (user as any)?.twoFactorEnabled === true, - { impersonation: typeof impersonatedBy === 'string' && impersonatedBy.trim() !== '' }, - ); - - return { - user: { ...user, positions, isPlatformAdmin: platformAdmin, ...(authGate ? { authGate } : {}) }, - session, - }; - })); - } - - return plugins; + return buildPluginList.call(host); } /** diff --git a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts index 22c80bf79dd..77eb7b7e326 100644 --- a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -321,7 +321,7 @@ function betterAuthPluginSet(): unknown[] { function authManagerPluginFactories(): string[] { // [#25224] The auth manager's source: `auth-manager.ts` and the method // bodies moved out of it whole. A factory imported in any of them is one it loads. - const source = ['auth-manager.ts', 'auth-manager-actor-guards.ts', 'auth-manager-organization-plugin.ts'] + const source = ['auth-manager.ts', 'auth-manager-actor-guards.ts', 'auth-manager-organization-plugin.ts', 'auth-manager-plugin-list.ts'] .map((file) => readFileSync(join(PKG, 'src', file), 'utf8')) .join('\n'); const found = new Set(); diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts index a31e1a8d9c8..e68bc461b5b 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -631,7 +631,7 @@ function findCollisions( function authManagerPluginFactories(): string[] { // [#25224] The auth manager's source: `auth-manager.ts` and the method // bodies moved out of it whole. A factory imported in any of them is one it loads. - const source = ['auth-manager.ts', 'auth-manager-actor-guards.ts', 'auth-manager-organization-plugin.ts'] + const source = ['auth-manager.ts', 'auth-manager-actor-guards.ts', 'auth-manager-organization-plugin.ts', 'auth-manager-plugin-list.ts'] .map((file) => readFileSync(join(HERE, file), 'utf8')) .join('\n'); const found = new Set();