From 9ab1f82787356d2cf383fb06f6f2ab14e9dcb7c0 Mon Sep 17 00:00:00 2001 From: Ken'ichiro Oyama Date: Fri, 26 Jun 2026 10:03:32 +0900 Subject: [PATCH 1/4] docs(auth): document Built-in IdP MFA (TOTP) feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Built-in IdP now supports TOTP-based multi-factor authentication (tailor-platform/sdk#1541, #1561, #1562; platform-core-services MFA work through #12233 / #12257). The user-facing guides under `docs/guides/` did not reflect the new surface, so app developers had no way to learn how to enable, enforce, or self-service MFA without reading the SDK reference page. This documents the consumer-facing behavior in the two guides that are docs-repo-native: - `docs/guides/auth/integration/built-in-idp.md` — add a Multi-Factor Authentication (TOTP) section under userAuthPolicy, splitting the two flows that are easy to conflate: (a) inline enrollment that the IdP /signin page handles itself when `requireMfa: true`, and (b) self-service factor management via `_requestMfaSettingsUrl`, which issues a per-user MFA settings page URL. Documents `enableMfa`, `requireMfa`, `allowedReturnOrigins`, `mfaIssuer`, the cross-field constraints, the `permission.unenrollMfa` requirement, and the two new GraphQL operations plus the `mfaEnrolled` / `mfaFactorIds` fields on the User type. Also extends the User Management op list. - `docs/guides/function/managing-idp-users.md` — extend the runtime `tailor.idp.Client` interface block with `User.mfaEnrolled`, `User.mfaFactorIds`, `UnenrollMfaInput`, and `client.unenrollMfa()`, plus an Unenroll MFA Factor section showing the typical admin recovery flow. `docs/sdk/services/idp.md` is intentionally not edited here — it is auto-synced from the SDK repo by the `sdk-docs-sync` workflow, and the SDK-side page already covers the SDK-shaped surface (gqlOperations MFA fields, schema constraints, runtime API). --- docs/guides/auth/integration/built-in-idp.md | 148 +++++++++++++++++++ docs/guides/function/managing-idp-users.md | 38 +++++ 2 files changed, 186 insertions(+) diff --git a/docs/guides/auth/integration/built-in-idp.md b/docs/guides/auth/integration/built-in-idp.md index f69c031..234c935 100644 --- a/docs/guides/auth/integration/built-in-idp.md +++ b/docs/guides/auth/integration/built-in-idp.md @@ -505,6 +505,152 @@ export const builtinIdp = defineIdp("builtin-idp", { When `disable_password_auth` is enabled, existing users who previously signed in with a password will need to use Google OAuth or Microsoft OAuth instead. Ensure that all users have accounts with the configured OAuth provider and email addresses in the allowed domains before enabling this setting. ::: +### Multi-Factor Authentication (TOTP) + +The Built-in IdP supports time-based one-time password (TOTP) MFA. Once enabled, users can register an authenticator app (Google Authenticator, 1Password, Authy, etc.), and you can optionally require MFA for every password-based sign-in. The label shown next to the user account in the authenticator app is the value of `mfaIssuer`. + +There are two distinct flows: + +**Mandatory enrollment during sign-in (when `requireMfa: true`)** + +The IdP sign-in page handles enrollment inline — no application code is needed. + +1. The user enters their password on the IdP sign-in page as usual. +2. If the user has no enrolled factor, the page shows a QR code, the user scans it into their authenticator app, and verifies a TOTP code. +3. Once verified, the sign-in completes and the OIDC callback fires. +4. On subsequent sign-ins, the page prompts only for the TOTP code. + +**Self-service management (any time `enableMfa: true`)** + +`_requestMfaSettingsUrl` issues an MFA settings page URL for the calling user. The signed-in user opens that URL to manage their own factors — add a new authenticator, remove a lost one, or opt in to MFA when `requireMfa: false`. + +1. Your application calls the `_requestMfaSettingsUrl` GraphQL query as the signed-in user, supplying a `returnTo` URL. +2. The IdP validates `returnTo` against `allowedReturnOrigins` and returns the URL. +3. The application redirects the user to that URL. +4. The user manages their factors (enroll a new device, remove an existing one) on the settings page. +5. The user is redirected back to `returnTo`. + +**Configuration:** + +```typescript +import { defineConfig, defineIdp, defineAuth, defineStaticWebSite } from "@tailor-platform/sdk"; +import { user } from "./tailordb/user"; + +const website = defineStaticWebSite("my-frontend", { description: "App frontend" }); + +const idp = defineIdp("builtin-idp", { + clients: ["main-client"], + userAuthPolicy: { + enableMfa: true, + requireMfa: false, + allowedReturnOrigins: [website.url, "https://admin.example.com"], + mfaIssuer: "My App", + }, + permission: { + create: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }], + read: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }], + update: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }], + delete: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }], + sendPasswordResetEmail: [ + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + ], + unenrollMfa: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }], + }, +}); +``` + +**Configuration options (inside `userAuthPolicy`):** + +- `enableMfa` (boolean) — Make TOTP MFA available for users in this namespace. Defaults to `false`. When `true`, the IdP exposes the MFA settings page and the `_requestMfaSettingsUrl` / `_unenrollMfa` GraphQL operations. +- `requireMfa` (boolean) — Force password-authenticated users to enroll and pass an MFA challenge on every sign-in. Defaults to `false`. Users who have not yet enrolled cannot sign in until they complete enrollment via the MFA settings page. +- `allowedReturnOrigins` (string[]) — Origins the IdP self-service pages (such as `/mfa/settings`) are allowed to redirect back to. Each entry is either a literal origin (`https://app.example.com`, scheme + host + optional port, no path/query/fragment) or a static-website placeholder `:url` (e.g. `website.url`) that the CLI resolves to the deployed website's URL at apply time. Required when `enableMfa` is `true`. Up to 50 entries. +- `mfaIssuer` (string, optional) — Label shown next to the user account in authenticator apps. Up to 64 characters. Falls back to `"Tailor Platform IdP"` when empty. + +**Permission option:** + +- `permission.unenrollMfa` — Controls who can remove an enrolled MFA factor from a user via the `_unenrollMfa` mutation. **Required when `enableMfa` is `true`.** Set `[{ conditions: [...], permit: true }]` to allow, or `[]` to deny all. Use the same operands and operators as the other permission categories. Typically restricted to administrators. + +**Constraints:** the following combinations are rejected at parse time. + +- `requireMfa: true` requires `enableMfa: true`. +- `enableMfa: true` requires at least one entry in `allowedReturnOrigins`. +- `enableMfa: true` requires `permission` to be defined and to include an explicit `unenrollMfa` policy. + +**Sign-in behavior:** + +- **`enableMfa: true`, `requireMfa: false`** — MFA is optional. Users opt in from their own MFA settings page (issued by `_requestMfaSettingsUrl`); once enrolled, they are challenged for TOTP on every subsequent sign-in. +- **`enableMfa: true`, `requireMfa: true`** — MFA is mandatory for password sign-in. The IdP sign-in page enrolls unenrolled users inline before completing the sign-in, so the application does not need to call `_requestMfaSettingsUrl` to bootstrap enrollment. +- **Social sign-in (Google / Microsoft OAuth)** — Not affected by `requireMfa`. MFA enforcement is the upstream provider's responsibility for these sessions. + +:::warning +`enableMfa` cannot be combined with `disablePasswordAuth: true`. Without password authentication, the MFA settings page has no way to authenticate the user, so the IdP does not expose `_requestMfaSettingsUrl` or `_unenrollMfa` for OAuth-only namespaces. +::: + +#### Issuing a per-user MFA settings page URL + +`_requestMfaSettingsUrl` returns an MFA settings page URL bound to the calling user. Call it from your application (typically from a "Security" / "Account" page) as the signed-in user, then redirect the browser to the returned URL: + +```graphql +query RequestMfaSettingsUrl($input: _RequestMfaSettingsUrlInput!) { + _requestMfaSettingsUrl(input: $input) { + url + } +} +``` + +Variables: + +```json +{ + "input": { + "returnTo": "https://app.example.com/account/security" + } +} +``` + +The `returnTo` URL must be an absolute URL whose origin matches one of the entries in `allowedReturnOrigins` (the comparison is case-insensitive and elides default ports). The IdP redirects the user back to `returnTo` after they finish enrolling or unenrolling factors. + +#### Inspecting MFA enrollment state + +The user record exposes two MFA fields. They are read live from Identity Platform on every request (never cached in TailorDB), so they always reflect the current enrollment state. + +```graphql +query GetUserMfa($userId: ID!) { + _user(id: $userId) { + id + name + mfaEnrolled + mfaFactorIds + } +} +``` + +- `mfaEnrolled` (Boolean!) — `true` when the user has at least one MFA factor enrolled. +- `mfaFactorIds` ([String!]!) — Identity Platform-issued IDs of the user's enrolled factors. Pass one of these to `_unenrollMfa` to remove a single factor. + +#### Removing a user's MFA factor (admin) + +Use `_unenrollMfa` to remove a single factor on behalf of a user (for example when a user has lost their authenticator device). The mutation is subject to the `unenrollMfa` permission policy and requires read access to the target user. + +```graphql +mutation UnenrollMfa($input: _UnenrollMfaInput!) { + _unenrollMfa(input: $input) +} +``` + +Variables: + +```json +{ + "input": { + "userId": "user-id-here", + "mfaFactorId": "factor-id-from-mfaFactorIds" + } +} +``` + +If `requireMfa: true` and the unenrolled user has no remaining factors, that user must enroll again before their next sign-in. + ### Email Configuration The Built-in IdP allows you to customize the sender name and subject line for emails sent by the IdP (such as password reset emails). You can configure namespace-level defaults using the `emailConfig` option, and optionally override them per request. @@ -583,6 +729,7 @@ Once registered, the IdP subgraph provides the following GraphQL operations for - `_users` - List IdP users with pagination and filtering - `_user` - Get a specific IdP user by ID - `_userBy` - Get a specific IdP user by ID or name +- `_requestMfaSettingsUrl` - Request a one-time URL to the IdP-hosted MFA settings page. Available when `enableMfa` is `true`. See [Multi-Factor Authentication (TOTP)](#multi-factor-authentication-totp). **Mutation Operations:** @@ -590,6 +737,7 @@ Once registered, the IdP subgraph provides the following GraphQL operations for - `_updateUser` - Update an existing IdP user - `_deleteUser` - Delete an IdP user - `_sendPasswordResetEmail` - Send a password reset email to an IdP user +- `_unenrollMfa` - Remove a single MFA factor from a user. Available when `enableMfa` is `true`. See [Multi-Factor Authentication (TOTP)](#multi-factor-authentication-totp). ### GraphQL Examples diff --git a/docs/guides/function/managing-idp-users.md b/docs/guides/function/managing-idp-users.md index c0f7782..82b1cb8 100644 --- a/docs/guides/function/managing-idp-users.md +++ b/docs/guides/function/managing-idp-users.md @@ -28,6 +28,8 @@ interface User { name: string; disabled: boolean; createdAt?: string; + mfaEnrolled: boolean; + mfaFactorIds: string[]; } interface UserQuery { @@ -68,6 +70,11 @@ interface SendPasswordResetEmailInput { subject?: string; } +interface UnenrollMfaInput { + userId: string; + mfaFactorId: string; +} + class Client { constructor(config: ClientConfig); users(options?: ListUsersOptions): Promise; @@ -77,6 +84,7 @@ class Client { updateUser(input: UpdateUserInput): Promise; deleteUser(userId: string): Promise; sendPasswordResetEmail(input: SendPasswordResetEmailInput): Promise; + unenrollMfa(input: UnenrollMfaInput): Promise; } ``` @@ -329,6 +337,36 @@ export default async (args) => { Password reset emails are sent from `no-reply@idp.erp.dev`. ::: +### Unenroll MFA Factor + +The `unenrollMfa` method removes a single MFA factor from a user, for example when a user has lost their authenticator device. Use a value from `User.mfaFactorIds` for `mfaFactorId`. The operation is subject to the namespace's `unenrollMfa` permission policy. + +```js +export default async (args) => { + const idpClient = new tailor.idp.Client({ namespace: args.namespaceName }); + + const user = await idpClient.user(args.userId); + if (!user.mfaEnrolled) { + return { success: false, reason: "User has no enrolled MFA factor" }; + } + + await Promise.all( + user.mfaFactorIds.map((mfaFactorId) => + idpClient.unenrollMfa({ userId: user.id, mfaFactorId }), + ), + ); + + return { success: true }; +}; +``` + +**Input fields:** + +- `userId` (string, required) — The ID of the user whose factor will be unenrolled. +- `mfaFactorId` (string, required) — The ID of the factor to unenroll. Factor IDs are returned on the user record as `mfaFactorIds`. + +This method is available only when the IdP namespace has `enableMfa: true`. See [Multi-Factor Authentication (TOTP)](/guides/auth/integration/built-in-idp#multi-factor-authentication-totp) for the full configuration. + ## Related Documentation - [Built-in IdP](/guides/auth/integration/built-in-idp) - Learn how to configure the Built-in IdP service From c288b80f72f0fa07c793589104432125b2152a90 Mon Sep 17 00:00:00 2001 From: Ken'ichiro Oyama Date: Fri, 26 Jun 2026 10:15:45 +0900 Subject: [PATCH 2/4] docs(auth): clarify requireMfa enrollment + unenrollMfa read requirement Fix two issues raised in Copilot review on #148: - built-in-idp.md: the `requireMfa` option description said unenrolled users must complete enrollment "via the MFA settings page", which contradicts the inline /signin enrollment flow described two paragraphs above. Rewrite to point at the inline flow and note that the application does not need to call _requestMfaSettingsUrl to bootstrap enrollment. - managing-idp-users.md: note that `unenrollMfa` additionally requires read access to the target user (enforced at the dataplane RPC, mirroring the built-in-idp guide), so a namespace that denies `read` cannot use `unenrollMfa` either. --- docs/guides/auth/integration/built-in-idp.md | 2 +- docs/guides/function/managing-idp-users.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/auth/integration/built-in-idp.md b/docs/guides/auth/integration/built-in-idp.md index 234c935..fdb7d04 100644 --- a/docs/guides/auth/integration/built-in-idp.md +++ b/docs/guides/auth/integration/built-in-idp.md @@ -562,7 +562,7 @@ const idp = defineIdp("builtin-idp", { **Configuration options (inside `userAuthPolicy`):** - `enableMfa` (boolean) — Make TOTP MFA available for users in this namespace. Defaults to `false`. When `true`, the IdP exposes the MFA settings page and the `_requestMfaSettingsUrl` / `_unenrollMfa` GraphQL operations. -- `requireMfa` (boolean) — Force password-authenticated users to enroll and pass an MFA challenge on every sign-in. Defaults to `false`. Users who have not yet enrolled cannot sign in until they complete enrollment via the MFA settings page. +- `requireMfa` (boolean) — Force password-authenticated users to enroll and pass an MFA challenge on every sign-in. Defaults to `false`. Unenrolled users are enrolled inline on the IdP sign-in page (see the **Mandatory enrollment during sign-in** flow above); the application does not need to call `_requestMfaSettingsUrl` to bootstrap enrollment. - `allowedReturnOrigins` (string[]) — Origins the IdP self-service pages (such as `/mfa/settings`) are allowed to redirect back to. Each entry is either a literal origin (`https://app.example.com`, scheme + host + optional port, no path/query/fragment) or a static-website placeholder `:url` (e.g. `website.url`) that the CLI resolves to the deployed website's URL at apply time. Required when `enableMfa` is `true`. Up to 50 entries. - `mfaIssuer` (string, optional) — Label shown next to the user account in authenticator apps. Up to 64 characters. Falls back to `"Tailor Platform IdP"` when empty. diff --git a/docs/guides/function/managing-idp-users.md b/docs/guides/function/managing-idp-users.md index 82b1cb8..969211a 100644 --- a/docs/guides/function/managing-idp-users.md +++ b/docs/guides/function/managing-idp-users.md @@ -339,7 +339,7 @@ Password reset emails are sent from `no-reply@idp.erp.dev`. ### Unenroll MFA Factor -The `unenrollMfa` method removes a single MFA factor from a user, for example when a user has lost their authenticator device. Use a value from `User.mfaFactorIds` for `mfaFactorId`. The operation is subject to the namespace's `unenrollMfa` permission policy. +The `unenrollMfa` method removes a single MFA factor from a user, for example when a user has lost their authenticator device. Use a value from `User.mfaFactorIds` for `mfaFactorId`. The operation is subject to the namespace's `unenrollMfa` permission policy and additionally requires read access to the target user (the same `read` policy that governs `user` / `userByName`); a namespace that denies `read` will reject `unenrollMfa` as well. ```js export default async (args) => { From 979a5b0bc97b07238da38f4c4c7196bd3fd3df77 Mon Sep 17 00:00:00 2001 From: Ken'ichiro Oyama Date: Fri, 26 Jun 2026 10:23:58 +0900 Subject: [PATCH 3/4] docs(auth): align unenrollMfa example with prose, trim MFA snippet imports Address Copilot review on c288b80: - managing-idp-users.md: prose said `unenrollMfa` removes a single factor while the only example unenrolled every factor via `Promise.all`, which a copy/paste would reproduce by mistake. Lead with the single-factor case (the API's actual unit) and present the full-reset loop as an explicit second example for the lost-device recovery flow. Pluralize the "no enrolled MFA factors" reason string while there. - built-in-idp.md: drop unused imports (`defineConfig`, `defineAuth`, `user`) from the MFA configuration snippet so the imports actually match what the snippet declares. --- docs/guides/auth/integration/built-in-idp.md | 3 +-- docs/guides/function/managing-idp-users.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/guides/auth/integration/built-in-idp.md b/docs/guides/auth/integration/built-in-idp.md index fdb7d04..9ae6ca2 100644 --- a/docs/guides/auth/integration/built-in-idp.md +++ b/docs/guides/auth/integration/built-in-idp.md @@ -533,8 +533,7 @@ The IdP sign-in page handles enrollment inline — no application code is needed **Configuration:** ```typescript -import { defineConfig, defineIdp, defineAuth, defineStaticWebSite } from "@tailor-platform/sdk"; -import { user } from "./tailordb/user"; +import { defineIdp, defineStaticWebSite } from "@tailor-platform/sdk"; const website = defineStaticWebSite("my-frontend", { description: "App frontend" }); diff --git a/docs/guides/function/managing-idp-users.md b/docs/guides/function/managing-idp-users.md index 969211a..4ea0c2d 100644 --- a/docs/guides/function/managing-idp-users.md +++ b/docs/guides/function/managing-idp-users.md @@ -339,7 +339,19 @@ Password reset emails are sent from `no-reply@idp.erp.dev`. ### Unenroll MFA Factor -The `unenrollMfa` method removes a single MFA factor from a user, for example when a user has lost their authenticator device. Use a value from `User.mfaFactorIds` for `mfaFactorId`. The operation is subject to the namespace's `unenrollMfa` permission policy and additionally requires read access to the target user (the same `read` policy that governs `user` / `userByName`); a namespace that denies `read` will reject `unenrollMfa` as well. +The `unenrollMfa` method removes a single MFA factor from a user, identified by `mfaFactorId`. The operation is subject to the namespace's `unenrollMfa` permission policy and additionally requires read access to the target user (the same `read` policy that governs `user` / `userByName`); a namespace that denies `read` will reject `unenrollMfa` as well. + +To remove one specific factor: + +```js +export default async (args) => { + const idpClient = new tailor.idp.Client({ namespace: args.namespaceName }); + await idpClient.unenrollMfa({ userId: args.userId, mfaFactorId: args.mfaFactorId }); + return { success: true }; +}; +``` + +To reset a user's MFA entirely — for example when the user has lost their authenticator device and needs to re-enroll on a new one — iterate over every entry in `User.mfaFactorIds`: ```js export default async (args) => { @@ -347,7 +359,7 @@ export default async (args) => { const user = await idpClient.user(args.userId); if (!user.mfaEnrolled) { - return { success: false, reason: "User has no enrolled MFA factor" }; + return { success: false, reason: "User has no enrolled MFA factors" }; } await Promise.all( From 3e49fcce2d445a026617a8cd586d4c5aa6b32e77 Mon Sep 17 00:00:00 2001 From: Ken'ichiro Oyama Date: Fri, 26 Jun 2026 10:31:38 +0900 Subject: [PATCH 4/4] docs(function): propagate unenrollMfa boolean in example snippets Address Copilot review on 979a5b0: `Client.unenrollMfa()` returns `Promise`, but both new examples discarded the result and returned `{ success: true }` unconditionally. That breaks the convention established by `deleteUser` / `sendPasswordResetEmail` elsewhere in this guide and would mask a false return from the API. - Single-factor example now captures and returns the boolean (`const success = await idpClient.unenrollMfa(...); return { success }`). - Reset-all example collects the per-factor booleans via `Promise.all` and aggregates with `results.every(Boolean)`. --- docs/guides/function/managing-idp-users.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/guides/function/managing-idp-users.md b/docs/guides/function/managing-idp-users.md index 4ea0c2d..53a774b 100644 --- a/docs/guides/function/managing-idp-users.md +++ b/docs/guides/function/managing-idp-users.md @@ -346,8 +346,11 @@ To remove one specific factor: ```js export default async (args) => { const idpClient = new tailor.idp.Client({ namespace: args.namespaceName }); - await idpClient.unenrollMfa({ userId: args.userId, mfaFactorId: args.mfaFactorId }); - return { success: true }; + const success = await idpClient.unenrollMfa({ + userId: args.userId, + mfaFactorId: args.mfaFactorId, + }); + return { success }; }; ``` @@ -362,13 +365,13 @@ export default async (args) => { return { success: false, reason: "User has no enrolled MFA factors" }; } - await Promise.all( + const results = await Promise.all( user.mfaFactorIds.map((mfaFactorId) => idpClient.unenrollMfa({ userId: user.id, mfaFactorId }), ), ); - return { success: true }; + return { success: results.every(Boolean) }; }; ```