Summary
Credential failures on POST /user_management/authenticate return a plain API error shape ({"code": "...", "message": "..."}). Production returns the OAuth token-endpoint shape ({"error": "...", "error_description": "..."}) for this class of failure, and the official Node SDK is built around that distinction — so against the emulator, SDK consumers never receive the OauthException their error mapping is written for.
The step-up errors are fine: the 403 organization_selection_required body is code-keyed and matches the spec (and the SDK's AUTHENTICATION_ERROR_CODES set expects exactly that). The divergence is only the credential-failure class: wrong/expired Magic Auth code, bad password, etc.
Reproduction (v0.6.0)
workos-emulate --port 4100 --seed seed.yaml & # seed one user: alice@acme.test
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:4100/user_management/authenticate \
-H "Content-Type: application/json" \
-d '{"grant_type":"urn:workos:oauth:grant-type:magic-auth:code","client_id":"client_x","client_secret":"sk_test_default","email":"alice@acme.test","code":"000000"}'
{"message":"Invalid code","code":"invalid_code"}
HTTP 400
Expected
{"error": "invalid_code", "error_description": "..."}
Why it breaks SDK consumers
@workos-inc/node (checked at 10.8.0) dispatches non-2xx authenticate responses in handleHttpError roughly as:
const { code, error_description: errorDescription, error, errors, message } = data;
// ...
default:
if (isAuthenticationErrorData(data)) throw new AuthenticationException(...); // code in AUTHENTICATION_ERROR_CODES only
else if (error || errorDescription) throw new OauthException(status, requestID, error, errorDescription, data);
else if (code && errors) throw new BadRequestException(...);
else throw new GenericServerException(...);
AUTHENTICATION_ERROR_CODES is the step-up set (email_verification_required, organization_selection_required, mfa_*, radar_*, sso_required) — invalid_code / invalid_grant are deliberately not in it, because production delivers those OAuth-style and they surface as OauthException with .error / .errorDescription.
With the emulator's {code, message} body, the chain falls through to GenericServerException: .error is undefined, so application code that maps error === "invalid_code" → user-facing copy (the shape production taught it) silently hits its generic fallback. Error-copy paths can't be exercised against the emulator.
Workaround
Error hooks with a hand-written OAuth-shaped body reproduce production exactly:
curl -X POST http://localhost:4100/_emulate/hooks -H "Content-Type: application/json" \
-d '{"method":"POST","path":"/user_management/authenticate","status":400,"body":{"error":"invalid_code","error_description":"Invalid code."},"count":1}'
That works for injected failures, but the emulator's own organic failures (wrong code, replayed rotated refresh token, expired invitation passed as invitation_token) still emit the plain shape.
Summary
Credential failures on
POST /user_management/authenticatereturn a plain API error shape ({"code": "...", "message": "..."}). Production returns the OAuth token-endpoint shape ({"error": "...", "error_description": "..."}) for this class of failure, and the official Node SDK is built around that distinction — so against the emulator, SDK consumers never receive theOauthExceptiontheir error mapping is written for.The step-up errors are fine: the 403
organization_selection_requiredbody is code-keyed and matches the spec (and the SDK'sAUTHENTICATION_ERROR_CODESset expects exactly that). The divergence is only the credential-failure class: wrong/expired Magic Auth code, bad password, etc.Reproduction (v0.6.0)
Expected
{"error": "invalid_code", "error_description": "..."}Why it breaks SDK consumers
@workos-inc/node(checked at 10.8.0) dispatches non-2xx authenticate responses inhandleHttpErrorroughly as:AUTHENTICATION_ERROR_CODESis the step-up set (email_verification_required,organization_selection_required,mfa_*,radar_*,sso_required) —invalid_code/invalid_grantare deliberately not in it, because production delivers those OAuth-style and they surface asOauthExceptionwith.error/.errorDescription.With the emulator's
{code, message}body, the chain falls through toGenericServerException:.erroris undefined, so application code that mapserror === "invalid_code"→ user-facing copy (the shape production taught it) silently hits its generic fallback. Error-copy paths can't be exercised against the emulator.Workaround
Error hooks with a hand-written OAuth-shaped body reproduce production exactly:
That works for injected failures, but the emulator's own organic failures (wrong code, replayed rotated refresh token, expired invitation passed as
invitation_token) still emit the plain shape.