diff --git a/.changeset/auth-mount-ledger-and-docs.md b/.changeset/auth-mount-ledger-and-docs.md new file mode 100644 index 0000000000..7bb37b08f6 --- /dev/null +++ b/.changeset/auth-mount-ledger-and-docs.md @@ -0,0 +1,13 @@ +--- +"@objectstack/plugin-auth": patch +--- + +Ledger and document the ObjectStack-owned auth mounts that were in neither the route ledger nor the docs (#10534). + +`auth-plugin.ts` mounts 17 routes directly on the raw Hono app ahead of the better-auth catch-all. A census found **nine** of them in neither half of `auth-route-ledger.ts`, and **six** with no literal wire path anywhere in the hand-written docs — the state that let a mount and its documentation gap ship separately with nothing objecting. + +**Ledger:** eight mounts gain reviewed `source: 'objectstack'` rows — `/admin/import-users`, `/admin/oauth2/toggle-disabled`, `/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification`, `/admin/sso/verify-domain`, `/admin/unlock-user`, `/sys-oauth-application/register`. All are `disposition: 'server-only'`: each was measured to have zero `ObjectStackClient` callers and exactly one real caller that is a declarative metadata action target or a Console wizard. `POST /api/v1/auth/set-initial-password` is deliberately left unledgered and escalated rather than given a guessed disposition. + +**Docs:** `GET /api/v1/auth/bootstrap-status`, `POST /api/v1/auth/set-initial-password`, `POST /api/v1/auth/admin/unban-user`, `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` are now documented with their literal wire paths, including the opt-in `OS_SSO_DOMAIN_VERIFICATION` domain-verification flow and the asymmetric way its two halves report the switch being off. + +No route's mounting, behaviour or accept/reject set changes. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index a7aa962e34..a414bdd37a 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -245,6 +245,33 @@ const session = await client.auth.me(); console.log('Current user:', session.data.user); ``` +### First-run bootstrap status + +`GET /api/v1/auth/bootstrap-status` answers one question — does this environment +have any user yet? + +```typescript +const res = await fetch('http://localhost:3000/api/v1/auth/bootstrap-status'); +const { hasOwner } = await res.json(); // → { "hasOwner": true } +``` + +It is **public and unauthenticated** by design: a client has to be able to ask it +*before* anyone has credentials. That is also why it returns nothing but the +boolean — it is a routing signal, not an information endpoint. The Console's root +route uses it to choose between `/login` (normal) and `/setup` (first-run owner +creation); `client.auth.bootstrapStatus()` is the SDK method that builds it. + +It **fails open**: if no data engine is wired, or the count query throws, it +answers `{ "hasOwner": true }` so a client falls through to the ordinary login +flow rather than offering to create an owner on an environment that may already +have one. + + +This route is unauthenticated, so treat `hasOwner: false` as a hint to render a +setup screen — never as authorization. First-run owner creation is enforced +server-side by the sign-up path, not by this probe. + + ### Password Management #### Request Password Reset @@ -275,6 +302,35 @@ const response = await fetch('http://localhost:3000/api/v1/auth/reset-password', }); ``` +#### Setting a first local password + +`POST /api/v1/auth/set-initial-password` sets an **initial** local password for a +signed-in user who has no credential account yet — the account was onboarded +through SSO, through the cloud OAuth provider, or imported with +`passwordPolicy: 'none'`. It gives that user an email/password way in to this +environment without the SSO round-trip. + +```typescript +const response = await fetch('http://localhost:3000/api/v1/auth/set-initial-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', // a valid session identifies WHO is asking + body: JSON.stringify({ newPassword: 'firstLocalPassword123' }) +}); +``` + +Two refusals are deliberate and are what separate this from a password reset: + +- **No session → refused.** The route never takes a user id from the body; the + session is the only thing that says whose password is being set. +- **A credential already exists → refused.** Use + `POST /api/v1/auth/change-password` in that case, so the *current* password is + verified first. This endpoint is only for the no-password-yet state, which is + why it can accept a new password without one. + +The Console reaches it from **Profile → Password** when `hasLocalPassword()` +reports no credential. + ### Email Verification #### Send Verification Email @@ -899,7 +955,8 @@ every row through better-auth so the accounts are login-capable: - `passwordPolicy: 'none'` — identity only: accounts are created without a credential record. Users first sign in through a channel (phone OTP, magic link, or a password-reset link) and the Console detects the missing password - (`hasLocalPassword()`) and offers set-initial-password. + (`hasLocalPassword()`) and offers + [`POST /api/v1/auth/set-initial-password`](#setting-a-first-local-password). - `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert updates only touch profile fields (`name`, `image`, `phone_number`, `role`) — a re-imported file can never modify an existing user's email or reset @@ -1002,11 +1059,13 @@ All endpoints are available under `/api/v1/auth/*`: #### Session - `GET /api/v1/auth/get-session` - Get current user session +- `GET /api/v1/auth/bootstrap-status` - Public, unauthenticated first-run probe: `{ "hasOwner": boolean }`, telling a client whether this environment has any user yet ([details](#first-run-bootstrap-status)) #### Password Management - `POST /api/v1/auth/request-password-reset` - Request password reset email - `POST /api/v1/auth/reset-password` - Reset password with token +- `POST /api/v1/auth/set-initial-password` - Set a **first** local password for a signed-in user who has no credential yet (SSO-onboarded accounts). Session required; refuses when a password already exists ([details](#setting-a-first-local-password)) #### Email Verification @@ -1042,6 +1101,8 @@ All endpoints are available under `/api/v1/auth/*`: - `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users) - `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported) - `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early +- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions) +- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in #### Organization Membership (requires `plugins.organization`; platform-admin gated) diff --git a/content/docs/permissions/sso.mdx b/content/docs/permissions/sso.mdx index 5eb1fc115c..c2769438ee 100644 --- a/content/docs/permissions/sso.mdx +++ b/content/docs/permissions/sso.mdx @@ -125,6 +125,20 @@ packages that prefer wiring providers in code, or contributing them through > below). The `oidcProviders` extension shown here remains the in-process path > for framework/enterprise packages that prefer wiring providers in code. +**Setup → SSO Providers → Register Provider** posts to the env-side bridge at +`POST /api/v1/auth/admin/sso/register`, the OIDC counterpart of the SAML bridge +[below](#enterprise-sso-saml-20). It takes the flat form fields of the +[OIDC provider fields](#oidc-provider-fields) table, reshapes them for +`@better-auth/sso`, and is gated on a **platform admin** (ADR-0068 D4) before it +delegates — an organization owner or admin is not sufficient, because registering +an identity provider decides how the whole environment authenticates. + + +This is an ObjectStack mount, distinct from `@better-auth/sso`'s own +`POST /api/v1/auth/sso/register`. The bridge exists so the no-code Setup form can +post flat fields; both doors apply the platform-admin rule. + + ### Quick start — Okta {/* os:check */} @@ -212,6 +226,68 @@ const oidcProviders = [ *Either `discoveryUrl` or `authorizationUrl` + `tokenUrl` must be provided. +### Domain verification (opt-in) + +A provider can claim an email **domain**, so that anyone signing in with an +address at that domain is routed to it. Proving the claim is opt-in per +environment (ADR-0024 ②) and off by default: + +```bash +# Off by default. Turn on to require a DNS proof before a domain claim counts. +OS_SSO_DOMAIN_VERIFICATION=true +``` + +With it on, **Setup → SSO Providers** exposes a two-step flow, one route per +step. Both are platform-admin gated (ADR-0068 D4) and both take the provider the +domain is being claimed for: + +1. `POST /api/v1/auth/admin/sso/request-domain-verification` — returns a DNS + **TXT** record to publish on the domain. Copy it into your DNS zone. +2. `POST /api/v1/auth/admin/sso/verify-domain` — call once the record has + propagated. It re-checks DNS and marks the domain verified, or reports why it + could not. + +```typescript +// Step 1 — ask for the TXT record to publish. +// Body: { providerId, domain? }. `domain` only shapes the record NAME shown back +// to you; omit it and you get the bare label to place on the zone yourself. +const req = await fetch('http://localhost:3000/api/v1/auth/admin/sso/request-domain-verification', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ providerId: 'okta', domain: 'acme.example' }) +}); +// → { success: true, data: { providerId, domain, token, +// dnsRecordType: 'TXT', dnsRecordName, dnsRecordValue } } + +// …publish dnsRecordName / dnsRecordValue, wait for DNS to propagate, then: + +// Step 2 — verify the claim. Body: { providerId }. +const done = await fetch('http://localhost:3000/api/v1/auth/admin/sso/verify-domain', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ providerId: 'okta' }) +}); +// → { success: true, data: { providerId, verified: true, message } } +``` + +Both take `providerId` and refuse with **400 `INVALID_REQUEST`** when it is +missing. Step 2 reports `NO_PENDING_VERIFICATION` if you call it before step 1, +and `DOMAIN_VERIFICATION_FAILED` when the TXT record is not visible yet — retry +after DNS propagates. + + +**The mounts are unconditional; the switch controls the endpoint behind them.** +Both routes exist whether or not `OS_SSO_DOMAIN_VERIFICATION` is set — and with +it unset the two halves report that differently, so match on the code rather than +the status: step 1 answers **400 `DOMAIN_VERIFICATION_DISABLED`**, step 2 passes +the inner **404** through with an explanatory message. Either way an anonymous +caller gets **401 `UNAUTHENTICATED`** and a signed-in non-platform-admin **403 +`PERMISSION_DENIED`** — identity is answered before capability, so a stranger +cannot use these routes to probe which features an environment has enabled. + + --- ## Enterprise SSO (SAML 2.0) diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts index ac5941f4b9..9091824932 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -175,11 +175,32 @@ describe('auth route ledger hygiene', () => { // the enumeration, which reads `.path`, never sees it either). Pinned so // the `source` split stays honest rather than becoming a place to park a // row that failed the upstream check. + // + // [#10534] Grew from 3 to 11. A census of `auth-plugin.ts` found 17 such + // mounts, of which nine were in NEITHER half of the ledger; eight are + // ledgered now. This pin is the thing that makes the enlarged set + // reviewable: an ObjectStack mount added or removed without a matching + // row fails HERE, naming the route, which is the closest mechanical check + // that exists today for the "mounted with no ledger row" state. It is not + // a substitute for the mount-vs-ledger gate #10534 proposes — this list + // is still hand-written, so it catches a row that disappears, not a mount + // that never got one. The ninth mount, + // `POST /api/v1/auth/set-initial-password`, is deliberately absent: its + // disposition is escalated on #10534 rather than guessed (see the ledger + // comment above these rows). const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort(); expect(own).toEqual([ 'GET /api/v1/auth/bootstrap-status', 'GET /api/v1/auth/config', + 'POST /api/v1/auth/admin/import-users', + 'POST /api/v1/auth/admin/oauth2/toggle-disabled', + 'POST /api/v1/auth/admin/sso/register', + 'POST /api/v1/auth/admin/sso/register-saml', + 'POST /api/v1/auth/admin/sso/request-domain-verification', + 'POST /api/v1/auth/admin/sso/verify-domain', + 'POST /api/v1/auth/admin/unlock-user', 'POST /api/v1/auth/organization/add-member', + 'POST /api/v1/auth/sys-oauth-application/register', ]); for (const route of own) { expect(live.has(route), `${route} should NOT come from better-auth`).toBe(false); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index c5ac8594ae..123e1a87dc 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -178,6 +178,54 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ { route: 'GET /api/v1/auth/oauth2/public-client', family: 'oauth-provider', source: 'better-auth', disposition: 'sdk', client: 'oauth.applications.getPublic', requires: 'oidcProvider' }, { route: 'GET /api/v1/auth/bootstrap-status', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.bootstrapStatus' }, { route: 'GET /api/v1/auth/config', family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: 'auth.getConfig' }, + // ───────────────────────────────────────────────────────────────────── + // #10534 — the remaining ObjectStack raw-app mounts, ledgered. + // + // A census of `auth-plugin.ts` found 17 routes mounted directly on the raw + // Hono app ahead of the catch-all, and NINE of them appeared in neither half + // of this file: not in the reviewed rows, and not in + // BETTER_AUTH_MOUNTED_SURFACE either (correctly — the vendor does not serve + // these paths, so an exact-equality inventory of the vendor's table cannot + // and must not carry them). Unaccounted-for is the state that let #9941 and + // #10050 ship a mount and its documentation gap separately with nothing + // objecting, so the rows are written here rather than left implied. + // + // WHY `server-only` FOR ALL OF THEM, and how that was decided rather than + // defaulted. `server-only` means "deliberately not SDK surface", so it is a + // claim about intent and not a leftover bucket. It was tested per route by + // asking who actually builds the URL — measured, with a positive control + // proving the search fires (`bootstrap-status` → 2 hits, `sign-in/email` → + // 2, `get-session` → 6 in `packages/client/src`). Every route below came + // back with ZERO `ObjectStackClient` callers and exactly one real caller + // that is a DECLARATIVE metadata action target or a Console wizard — the + // `organization/add-member` precedent directly above. Their peer routes + // (`/admin/create-user`, `/admin/ban-user`, `/admin/set-user-password`) are + // uniformly SDK-absent too, so "the SDK deliberately does not cover + // platform-operator user administration" is the surface's actual shape, not + // an accommodation written to make a row fit. + // + // ⚠️ `POST /api/v1/auth/set-initial-password` is the ninth mount and is + // DELIBERATELY NOT LEDGERED HERE. It fails the test above in a way none of + // these do: its caller is `@object-ui/auth`'s `createAuthClient`, whose + // three other auth URLs (`/config`, `/get-session`, `/list-accounts`) are + // ALL expressed on `ObjectStackClient` — and its own sibling branch in the + // same Console password card, `changePassword`, is ledgered `sdk`. That + // shape reads as `gap` ("should be in the SDK and is not"), not as + // `server-only`, and `gap` is ratcheted to zero by this file's conformance + // suite. Writing `server-only` there would be a false declaration of intent + // to dodge a ratchet. It is escalated on #10534 instead. + // + // `requires` follows the add-member precedent: it names the better-auth + // plugin the route's WORK needs, not whether the mount is conditional — + // every one of these is mounted unconditionally on the raw app. + { route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068), #2766 V2' }, + { route: 'POST /api/v1/auth/admin/oauth2/toggle-disabled', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application disable/enable actions post it directly; ObjectStack mount closing a vendor gap (better-auth\'s /admin/oauth2/update-client strips `disabled` from its body schema), platform-admin gated (ADR-0068)' }, + { route: 'POST /api/v1/auth/admin/sso/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register action posts flat form fields; ObjectStack bridge re-dispatching into @better-auth/sso /sso/register, platform-admin gated ahead of the delegation (ADR-0068 D4, #9653). Distinct path from the vendor\'s own /sso/register, which the catch-all serves' }, + { route: 'POST /api/v1/auth/admin/sso/register-saml', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider register_saml_provider action posts flat fields the bridge reshapes into better-auth\'s nested samlConfig; platform-admin gated (ADR-0068 D4, #9653), ADR-0069 P3' }, + { route: 'POST /api/v1/auth/admin/sso/request-domain-verification', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider action posts it and renders the returned DNS TXT record; ObjectStack bridge over @better-auth/sso, additionally gated on the opt-in ssoDomainVerification switch (OS_SSO_DOMAIN_VERIFICATION) — off means the inner endpoint 404s, the mount itself is unconditional; platform-admin gated (ADR-0068 D4), ADR-0024 ②' }, + { route: 'POST /api/v1/auth/admin/sso/verify-domain', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', requires: 'sso', note: 'no SDK method builds this URL — the sys_sso_provider action posts it after the DNS TXT record is published; same opt-in ssoDomainVerification switch and platform-admin gate as request-domain-verification (ADR-0068 D4), ADR-0024 ②' }, + { route: 'POST /api/v1/auth/admin/unlock-user', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user unlock_user action posts it directly; clears a brute-force lockout (sys_user.locked_until / failed_login_count), a custom per-identity mechanism with no better-auth endpoint; platform-admin gated (ADR-0068), ADR-0069 D2' }, + { route: 'POST /api/v1/auth/sys-oauth-application/register', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application create action posts it directly; session-required self-service wrapper over better-auth /oauth2/create-client that splits the Console\'s newline-separated redirect-URL textarea into the redirect_uris array the vendor schema requires' }, { route: 'POST /api/v1/auth/organization/accept-invitation', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.invitations.accept', requires: 'organization' }, // #9941 — better-auth declares `addMember` with NO HTTP path (server-only // `auth.api.addMember`; measured on the installed 1.7.1), so the catch-all