diff --git a/.changeset/otp-generate-post.md b/.changeset/otp-generate-post.md new file mode 100644 index 0000000..0b2b961 --- /dev/null +++ b/.changeset/otp-generate-post.md @@ -0,0 +1,9 @@ +--- +'@seamless-auth/react': minor +--- + +Request OTP generation over `POST` instead of `GET`. + +`requestPhoneOtp`, `requestEmailOtp`, `requestLoginPhoneOtp`, and `requestLoginEmailOtp` each caused an SMS or email to be sent, so they were state changing. Sent as a `GET`, they were simple cross-site requests, so an `` on any page could trigger unbounded OTP messages to a signed-in user. They now POST an empty JSON body, which forces a CORS preflight and closes the vector. This mirrors the `requestMagicLink` change. + +BREAKING: this requires an adapter serving these routes over POST (`@seamless-auth/express` with the OTP POST change, released alongside this). Against an older adapter the four request methods get a 404. Callers need no code change. diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 7492af4..c713966 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -480,7 +480,13 @@ export const createSeamlessAuthClient = ( requestPhoneOtp: () => requestResult( - fetchWithAuth(`/otp/generate-phone-otp`, { method: 'GET' }), + // POST with an empty JSON body on purpose. These routes send an SMS or + // email, so they are state changing. The JSON content type forces a CORS + // preflight, which keeps them from being triggered by a cross-site GET. + fetchWithAuth(`/otp/generate-phone-otp`, { + method: 'POST', + body: JSON.stringify({}), + }), 'Failed to send the SMS code.' ), @@ -495,7 +501,13 @@ export const createSeamlessAuthClient = ( requestLoginPhoneOtp: () => requestResult( - fetchWithAuth(`/otp/generate-login-phone-otp`, { method: 'GET' }), + // POST with an empty JSON body on purpose. These routes send an SMS or + // email, so they are state changing. The JSON content type forces a CORS + // preflight, which keeps them from being triggered by a cross-site GET. + fetchWithAuth(`/otp/generate-login-phone-otp`, { + method: 'POST', + body: JSON.stringify({}), + }), 'Failed to send the SMS code.' ), @@ -510,7 +522,13 @@ export const createSeamlessAuthClient = ( requestEmailOtp: () => requestResult( - fetchWithAuth(`/otp/generate-email-otp`, { method: 'GET' }), + // POST with an empty JSON body on purpose. These routes send an SMS or + // email, so they are state changing. The JSON content type forces a CORS + // preflight, which keeps them from being triggered by a cross-site GET. + fetchWithAuth(`/otp/generate-email-otp`, { + method: 'POST', + body: JSON.stringify({}), + }), 'Failed to send the email code.' ), @@ -525,7 +543,13 @@ export const createSeamlessAuthClient = ( requestLoginEmailOtp: () => requestResult( - fetchWithAuth(`/otp/generate-login-email-otp`, { method: 'GET' }), + // POST with an empty JSON body on purpose. These routes send an SMS or + // email, so they are state changing. The JSON content type forces a CORS + // preflight, which keeps them from being triggered by a cross-site GET. + fetchWithAuth(`/otp/generate-login-email-otp`, { + method: 'POST', + body: JSON.stringify({}), + }), 'Failed to send the email code.' ), diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index b40595f..4785f30 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -103,9 +103,7 @@ describe('createSeamlessAuthClient', () => { expect(mockFetchWithAuth).toHaveBeenNthCalledWith( 1, '/otp/generate-login-phone-otp', - { - method: 'GET', - } + { method: 'POST', body: JSON.stringify({}) } ); expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/otp/verify-login-phone-otp', { method: 'POST', @@ -113,6 +111,31 @@ describe('createSeamlessAuthClient', () => { }); }); + it('requests OTP generation over POST so the routes are not simple GETs', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + }); + + await client.requestPhoneOtp(); + await client.requestEmailOtp(); + await client.requestLoginPhoneOtp(); + await client.requestLoginEmailOtp(); + + // A bodyless GET would be a simple cross-site request. POST with a JSON body + // forces a preflight, which is the whole point of the change. + for (const [path] of mockFetchWithAuth.mock.calls) { + expect(path).toMatch(/\/otp\/generate-/); + } + for (const [, init] of mockFetchWithAuth.mock.calls) { + expect(init).toEqual({ method: 'POST', body: JSON.stringify({}) }); + } + }); + it('uses login-specific email OTP endpoints', async () => { const response = { ok: true }; mockFetchWithAuth.mockResolvedValue(response); @@ -127,7 +150,7 @@ describe('createSeamlessAuthClient', () => { expect(mockFetchWithAuth).toHaveBeenNthCalledWith( 1, '/otp/generate-login-email-otp', - expect.objectContaining({ method: 'GET' }) + { method: 'POST', body: JSON.stringify({}) } ); expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/otp/verify-login-email-otp', { method: 'POST',