Skip to content

Commit 482313e

Browse files
authored
docs(auth): document Built-in IdP MFA (TOTP) feature (#148)
* docs(auth): document Built-in IdP MFA (TOTP) feature 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(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(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(function): propagate unenrollMfa boolean in example snippets Address Copilot review on 979a5b0: `Client.unenrollMfa()` returns `Promise<boolean>`, 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)`.
1 parent 96719c8 commit 482313e

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

docs/guides/auth/integration/built-in-idp.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,151 @@ export const builtinIdp = defineIdp("builtin-idp", {
505505
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.
506506
:::
507507

508+
### Multi-Factor Authentication (TOTP)
509+
510+
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`.
511+
512+
There are two distinct flows:
513+
514+
**Mandatory enrollment during sign-in (when `requireMfa: true`)**
515+
516+
The IdP sign-in page handles enrollment inline — no application code is needed.
517+
518+
1. The user enters their password on the IdP sign-in page as usual.
519+
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.
520+
3. Once verified, the sign-in completes and the OIDC callback fires.
521+
4. On subsequent sign-ins, the page prompts only for the TOTP code.
522+
523+
**Self-service management (any time `enableMfa: true`)**
524+
525+
`_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`.
526+
527+
1. Your application calls the `_requestMfaSettingsUrl` GraphQL query as the signed-in user, supplying a `returnTo` URL.
528+
2. The IdP validates `returnTo` against `allowedReturnOrigins` and returns the URL.
529+
3. The application redirects the user to that URL.
530+
4. The user manages their factors (enroll a new device, remove an existing one) on the settings page.
531+
5. The user is redirected back to `returnTo`.
532+
533+
**Configuration:**
534+
535+
```typescript
536+
import { defineIdp, defineStaticWebSite } from "@tailor-platform/sdk";
537+
538+
const website = defineStaticWebSite("my-frontend", { description: "App frontend" });
539+
540+
const idp = defineIdp("builtin-idp", {
541+
clients: ["main-client"],
542+
userAuthPolicy: {
543+
enableMfa: true,
544+
requireMfa: false,
545+
allowedReturnOrigins: [website.url, "https://admin.example.com"],
546+
mfaIssuer: "My App",
547+
},
548+
permission: {
549+
create: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }],
550+
read: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }],
551+
update: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }],
552+
delete: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }],
553+
sendPasswordResetEmail: [
554+
{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true },
555+
],
556+
unenrollMfa: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }],
557+
},
558+
});
559+
```
560+
561+
**Configuration options (inside `userAuthPolicy`):**
562+
563+
- `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.
564+
- `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.
565+
- `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 `<name>: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.
566+
- `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.
567+
568+
**Permission option:**
569+
570+
- `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.
571+
572+
**Constraints:** the following combinations are rejected at parse time.
573+
574+
- `requireMfa: true` requires `enableMfa: true`.
575+
- `enableMfa: true` requires at least one entry in `allowedReturnOrigins`.
576+
- `enableMfa: true` requires `permission` to be defined and to include an explicit `unenrollMfa` policy.
577+
578+
**Sign-in behavior:**
579+
580+
- **`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.
581+
- **`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.
582+
- **Social sign-in (Google / Microsoft OAuth)** — Not affected by `requireMfa`. MFA enforcement is the upstream provider's responsibility for these sessions.
583+
584+
:::warning
585+
`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.
586+
:::
587+
588+
#### Issuing a per-user MFA settings page URL
589+
590+
`_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:
591+
592+
```graphql
593+
query RequestMfaSettingsUrl($input: _RequestMfaSettingsUrlInput!) {
594+
_requestMfaSettingsUrl(input: $input) {
595+
url
596+
}
597+
}
598+
```
599+
600+
Variables:
601+
602+
```json
603+
{
604+
"input": {
605+
"returnTo": "https://app.example.com/account/security"
606+
}
607+
}
608+
```
609+
610+
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.
611+
612+
#### Inspecting MFA enrollment state
613+
614+
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.
615+
616+
```graphql
617+
query GetUserMfa($userId: ID!) {
618+
_user(id: $userId) {
619+
id
620+
name
621+
mfaEnrolled
622+
mfaFactorIds
623+
}
624+
}
625+
```
626+
627+
- `mfaEnrolled` (Boolean!) — `true` when the user has at least one MFA factor enrolled.
628+
- `mfaFactorIds` ([String!]!) — Identity Platform-issued IDs of the user's enrolled factors. Pass one of these to `_unenrollMfa` to remove a single factor.
629+
630+
#### Removing a user's MFA factor (admin)
631+
632+
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.
633+
634+
```graphql
635+
mutation UnenrollMfa($input: _UnenrollMfaInput!) {
636+
_unenrollMfa(input: $input)
637+
}
638+
```
639+
640+
Variables:
641+
642+
```json
643+
{
644+
"input": {
645+
"userId": "user-id-here",
646+
"mfaFactorId": "factor-id-from-mfaFactorIds"
647+
}
648+
}
649+
```
650+
651+
If `requireMfa: true` and the unenrolled user has no remaining factors, that user must enroll again before their next sign-in.
652+
508653
### Email Configuration
509654

510655
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,13 +728,15 @@ Once registered, the IdP subgraph provides the following GraphQL operations for
583728
- `_users` - List IdP users with pagination and filtering
584729
- `_user` - Get a specific IdP user by ID
585730
- `_userBy` - Get a specific IdP user by ID or name
731+
- `_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).
586732

587733
**Mutation Operations:**
588734

589735
- `_createUser` - Create a new IdP user
590736
- `_updateUser` - Update an existing IdP user
591737
- `_deleteUser` - Delete an IdP user
592738
- `_sendPasswordResetEmail` - Send a password reset email to an IdP user
739+
- `_unenrollMfa` - Remove a single MFA factor from a user. Available when `enableMfa` is `true`. See [Multi-Factor Authentication (TOTP)](#multi-factor-authentication-totp).
593740

594741
### GraphQL Examples
595742

docs/guides/function/managing-idp-users.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface User {
2828
name: string;
2929
disabled: boolean;
3030
createdAt?: string;
31+
mfaEnrolled: boolean;
32+
mfaFactorIds: string[];
3133
}
3234

3335
interface UserQuery {
@@ -68,6 +70,11 @@ interface SendPasswordResetEmailInput {
6870
subject?: string;
6971
}
7072

73+
interface UnenrollMfaInput {
74+
userId: string;
75+
mfaFactorId: string;
76+
}
77+
7178
class Client {
7279
constructor(config: ClientConfig);
7380
users(options?: ListUsersOptions): Promise<ListUsersResponse>;
@@ -77,6 +84,7 @@ class Client {
7784
updateUser(input: UpdateUserInput): Promise<User>;
7885
deleteUser(userId: string): Promise<boolean>;
7986
sendPasswordResetEmail(input: SendPasswordResetEmailInput): Promise<boolean>;
87+
unenrollMfa(input: UnenrollMfaInput): Promise<boolean>;
8088
}
8189
```
8290

@@ -329,6 +337,51 @@ export default async (args) => {
329337
Password reset emails are sent from `no-reply@idp.erp.dev`.
330338
:::
331339

340+
### Unenroll MFA Factor
341+
342+
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.
343+
344+
To remove one specific factor:
345+
346+
```js
347+
export default async (args) => {
348+
const idpClient = new tailor.idp.Client({ namespace: args.namespaceName });
349+
const success = await idpClient.unenrollMfa({
350+
userId: args.userId,
351+
mfaFactorId: args.mfaFactorId,
352+
});
353+
return { success };
354+
};
355+
```
356+
357+
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`:
358+
359+
```js
360+
export default async (args) => {
361+
const idpClient = new tailor.idp.Client({ namespace: args.namespaceName });
362+
363+
const user = await idpClient.user(args.userId);
364+
if (!user.mfaEnrolled) {
365+
return { success: false, reason: "User has no enrolled MFA factors" };
366+
}
367+
368+
const results = await Promise.all(
369+
user.mfaFactorIds.map((mfaFactorId) =>
370+
idpClient.unenrollMfa({ userId: user.id, mfaFactorId }),
371+
),
372+
);
373+
374+
return { success: results.every(Boolean) };
375+
};
376+
```
377+
378+
**Input fields:**
379+
380+
- `userId` (string, required) — The ID of the user whose factor will be unenrolled.
381+
- `mfaFactorId` (string, required) — The ID of the factor to unenroll. Factor IDs are returned on the user record as `mfaFactorIds`.
382+
383+
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.
384+
332385
## Related Documentation
333386

334387
- [Built-in IdP](/guides/auth/integration/built-in-idp) - Learn how to configure the Built-in IdP service

0 commit comments

Comments
 (0)