Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/auth-mount-ledger-and-docs.md
Original file line numberDiff line numberDiff line change
@@ -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.
63 changes: 62 additions & 1 deletion content/docs/permissions/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

<Callout type="warn">
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.
</Callout>

### Password Management

#### Request Password Reset
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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)

Expand Down
76 changes: 76 additions & 0 deletions content/docs/permissions/sso.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

<Callout type="info">
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.
</Callout>

### Quick start — Okta

{/* os:check */}
Expand DownExpand Up@@ -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.

<Callout type="info">
**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.
</Callout>

---

## Enterprise SSO (SAML 2.0)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
48 changes: 48 additions & 0 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading