Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/otp-generate-post.md
Original file line number Diff line number Diff line change
@@ -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 `<img src>` 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.
32 changes: 28 additions & 4 deletions src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,13 @@ export const createSeamlessAuthClient = (

requestPhoneOtp: () =>
requestResult<MessageResult>(
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.'
),

Expand All @@ -495,7 +501,13 @@ export const createSeamlessAuthClient = (

requestLoginPhoneOtp: () =>
requestResult<MessageResult>(
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.'
),

Expand All @@ -510,7 +522,13 @@ export const createSeamlessAuthClient = (

requestEmailOtp: () =>
requestResult<MessageResult>(
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.'
),

Expand All @@ -525,7 +543,13 @@ export const createSeamlessAuthClient = (

requestLoginEmailOtp: () =>
requestResult<MessageResult>(
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.'
),

Expand Down
31 changes: 27 additions & 4 deletions tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,39 @@ 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',
body: JSON.stringify({ verificationToken: '123456' }),
});
});

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);
Expand All @@ -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',
Expand Down
Loading