Skip to content

authorization_code and refresh_token failures should use OAuth {error, error_description}; magic-auth wrong code returns invalid_code instead of invalid_one_time_code #51

Description

@grayashh

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions