Skip to content

Commit dc47d3a

Browse files
authored
Merge pull request #25 from bordoni/fix/unknown-email-creates-new-account
fix(auth): Per-form magic-code registration toggle with anti-enumeration [CONS-350]
2 parents 4f80489 + 2e8259c commit dc47d3a

7 files changed

Lines changed: 315 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
- 7 new activity-log event types: `email_change.initiated|confirmed|cancelled|expired|conflict_blocked|commit_failed|admin_bypass`.
1818
- 40 new WPUnit tests across 6 suites under `tests/wpunit/ChangeEmail*Test.php`.
1919
- See [`docs/change-email.md`](docs/change-email.md).
20+
- **Per-form magic-code registration toggles** ([CONS-350](https://linear.app/nexcess/issue/CONS-350)) (#25) — two independent per-environment checkboxes on the WorkOS settings page gate whether an unknown email signing in with a magic code provisions a new account.
21+
- **Email Code Registration** controls the default sign-in form (`/login/`); **Legacy Email Code Registration** controls the legacy form (`/login/legacy/`). The legacy profile slug (`legacy` by default) is filterable via `workos_legacy_profile_slug` and resolved from the `$profile` that `BaseEndpoint::resolve_profile()` already produces.
22+
- When a form's toggle is off, `POST /auth/magic/send` skips the WorkOS call for unknown addresses and still returns `200 ok: true`, and `POST /auth/magic/verify` early-returns a generic `400 workos_authkit_invalid_code` instead of proceeding to `LoginCompleter`/`UserSync` — closing the account-enumeration leak where `send` previously returned `404 workos_authkit_no_account`.
23+
- New options `allow_magic_code_registration` and `allow_legacy_magic_code_registration` (both default `true`, preserving historical behavior). `render_checkbox()` now honors a `default` key so the boxes render checked before first save.
2024

2125
### Fixed
2226

readme.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ WorkOS is provided by WorkOS, Inc.
178178
= 1.0.6 - Unreleased =
179179

180180
* New: WorkOS-verified change-email flow. Self-service `[workos:change-email]` shortcode + admin row action on `wp-admin/users.php` + panel on the user-edit screen. The new address must be confirmed via a hashed token emailed by the plugin (because WorkOS's `email_verification` endpoints can't verify a *pending* change); the old address simultaneously receives a one-click cancel link. Configurable conflict policy (`block` default, `allow_orphan`, `merge_request`) keeps the new email from silently overwriting another local WP user. Commits to WorkOS first (`update_user`) and then mirrors into WordPress, with a 60-second in-progress transient that short-circuits the webhook fan-back. Eight new filters, five new actions, seven new activity-log events, and 40 new WPUnit tests. See `docs/change-email.md`. (#22)
181+
* New: Per-form magic-code registration toggles. Two independent per-environment checkboxes — Email Code Registration (default sign-in form, `/login/`) and Legacy Email Code Registration (legacy `/login/legacy/` form) — gate whether an unknown email signing in with a magic code provisions a new account. When a form's toggle is off, `POST /auth/magic/send` silently skips the WorkOS call for unknown addresses and still returns `200 ok: true`, and `POST /auth/magic/verify` returns a generic `400 workos_authkit_invalid_code` instead of creating the user — closing the account-enumeration leak where the endpoint previously returned `404 workos_authkit_no_account`. Both toggles default on to preserve existing behavior. ([CONS-350](https://linear.app/nexcess/issue/CONS-350)) (#25)
181182

182183
= 1.0.5 - 2026-05-18 =
183184

src/WorkOS/Admin/Settings.php

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,32 @@ static function (): void {
678678
]
679679
);
680680

681+
add_settings_field(
682+
'workos_env_allow_magic_code_registration',
683+
__( 'Email Code Registration', 'integration-workos' ),
684+
[ $this, 'render_checkbox' ],
685+
'workos',
686+
'workos_auth',
687+
[
688+
'name' => $this->env_option( 'allow_magic_code_registration' ),
689+
'label' => __( 'Allow creating a new account when someone signs in with an email code for an address that has no account. When off, unknown emails receive no code and no account is created.', 'integration-workos' ),
690+
'default' => true,
691+
]
692+
);
693+
694+
add_settings_field(
695+
'workos_env_allow_legacy_magic_code_registration',
696+
__( 'Legacy Email Code Registration', 'integration-workos' ),
697+
[ $this, 'render_checkbox' ],
698+
'workos',
699+
'workos_auth',
700+
[
701+
'name' => $this->env_option( 'allow_legacy_magic_code_registration' ),
702+
'label' => __( 'Allow creating a new account when a legacy customer signs in with an email code for an address that has no account. When off, unknown emails receive no code and no account is created.', 'integration-workos' ),
703+
'default' => true,
704+
]
705+
);
706+
681707
// --- Audit Logging section ---
682708
add_settings_section(
683709
'workos_audit',
@@ -1268,10 +1294,16 @@ public function render_select( array $args ): void {
12681294
/**
12691295
* Render a checkbox.
12701296
*
1271-
* @param array $args Field arguments.
1297+
* @param array $args {
1298+
* Field arguments.
1299+
*
1300+
* @type string $name Option name (supports `group[key]` syntax).
1301+
* @type string $label Checkbox label.
1302+
* @type bool $default Value shown when the option has never been saved. Defaults to false.
1303+
* }
12721304
*/
12731305
public function render_checkbox( array $args ): void {
1274-
$value = $this->get_field_value( $args['name'], false );
1306+
$value = $this->get_field_value( $args['name'], $args['default'] ?? false );
12751307
printf(
12761308
'<input type="hidden" name="%s" value="0" />',
12771309
esc_attr( $args['name'] )
@@ -2037,7 +2069,7 @@ public function sanitize_environment_options( $input ): array {
20372069
}
20382070

20392071
// Boolean fields.
2040-
$bool_keys = [ 'allow_password_fallback', 'audit_logging_enabled', 'wp_password_fallback_email_confirmation' ];
2072+
$bool_keys = [ 'allow_password_fallback', 'audit_logging_enabled', 'wp_password_fallback_email_confirmation', 'allow_magic_code_registration', 'allow_legacy_magic_code_registration' ];
20412073
foreach ( $bool_keys as $key ) {
20422074
if ( isset( $input[ $key ] ) ) {
20432075
$sanitized[ $key ] = rest_sanitize_boolean( $input[ $key ] );

src/WorkOS/Options/Production.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ protected function defaults(): array {
5353
'change_email_require_reauth' => true,
5454
'change_email_admin_bypass_verification' => false,
5555
'change_email_confirm_path' => 'workos/change-email',
56+
'allow_magic_code_registration' => true,
57+
'allow_legacy_magic_code_registration' => true,
5658
];
5759
}
5860
}

src/WorkOS/Options/Staging.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ protected function defaults(): array {
5353
'change_email_require_reauth' => true,
5454
'change_email_admin_bypass_verification' => false,
5555
'change_email_confirm_path' => 'workos/change-email',
56+
'allow_magic_code_registration' => true,
57+
'allow_legacy_magic_code_registration' => true,
5658
];
5759
}
5860
}

src/WorkOS/REST/Auth/MagicCode.php

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,17 @@ public function send( WP_REST_Request $request ) {
9797
return $rate_ok;
9898
}
9999

100-
// Fire-and-forget: return 200 regardless so the client cannot enumerate
101-
// registered accounts. Real errors land in the plugin log.
102-
workos()->api()->send_magic_auth_code(
103-
$email,
104-
$this->get_radar_token( $request )
105-
);
100+
// If email registration is disabled, only known accounts get a code.
101+
// For unknown emails, skip the WorkOS call but still return success
102+
// so account existence isn't exposed.
103+
if ( $this->registration_allowed( $profile ) || get_user_by( 'email', $email ) ) {
104+
// Fire-and-forget on the WorkOS call: delivery errors land in the
105+
// plugin log rather than being surfaced to the client.
106+
workos()->api()->send_magic_auth_code(
107+
$email,
108+
$this->get_radar_token( $request )
109+
);
110+
}
106111

107112
return new WP_REST_Response(
108113
[
@@ -162,6 +167,18 @@ public function verify( WP_REST_Request $request ) {
162167
return $rate_ok;
163168
}
164169

170+
// Ensure verify never creates accounts when registration is disabled.
171+
// `send` skips unknown emails; this prevents race conditions or direct
172+
// API calls from provisioning users. Return a generic invalid-code error
173+
// to avoid account enumeration.
174+
if ( ! $this->registration_allowed( $profile ) && ! get_user_by( 'email', $email ) ) {
175+
return new WP_Error(
176+
'workos_authkit_invalid_code',
177+
__( 'That code is invalid or has expired.', 'integration-workos' ),
178+
[ 'status' => 400 ]
179+
);
180+
}
181+
165182
$workos_response = workos()->api()->authenticate_with_magic_auth(
166183
$email,
167184
$code,
@@ -184,4 +201,28 @@ public function verify( WP_REST_Request $request ) {
184201

185202
return new WP_REST_Response( $result, 200 );
186203
}
204+
205+
/**
206+
* Whether email-code sign-in may create a new account for the given profile.
207+
*
208+
* Scoped per form: the legacy customer profile reads its own admin toggle
209+
* (`allow_legacy_magic_code_registration`) so it can be locked down without
210+
* affecting the default sign-in, which keeps creating accounts for new
211+
* customers (`allow_magic_code_registration`). Both default to true to
212+
* preserve historical behaviour. The legacy profile slug defaults to
213+
* `legacy` (matching the portal's /login/legacy/ form) and is filterable.
214+
*
215+
* @param Profile $profile Resolved login profile for the request.
216+
*
217+
* @return bool
218+
*/
219+
private function registration_allowed( Profile $profile ): bool {
220+
$legacy_slug = (string) apply_filters( 'workos_legacy_profile_slug', 'legacy' );
221+
222+
$option = $profile->get_slug() === $legacy_slug
223+
? 'allow_legacy_magic_code_registration' // Legacy form toggle.
224+
: 'allow_magic_code_registration'; // Default form toggle.
225+
226+
return (bool) workos()->option( $option, true );
227+
}
187228
}

0 commit comments

Comments
 (0)