Repository: soofstad/react-oauth2-pkce
Pull request #188 (2024-09-26 08:24:30)

Fetch with credentials in postWithXForm()
## What does this pull request change?
It enables credentials when doing fetch requests.
Specifically it enables TLS client certificate authentication for OPTIONS requests in Firefox.
This fixes 403/forbidden errors, that would happen otherwise, since the request would be done without client certificate authentication and thus fail with 403 when using Firefox.
 
## Why is this pull request needed?
When the server is enforcing client certificate authentication, we must set the fetch option "credentials" to "include", to ensure all browsers are allowing client certificate authentication. For example, this is required in Firefox for "OPTIONS" requests.

## Issues related to this change

This will require the server to respond with proper CORS response headers, but this should already be the case (for example when using Keycloak).
If you prefer to make this configurable, I am willing to implement this, just let me know how you would like to implement it.


soofstad: Hi @MichaelRoosz 
Thank you for the pull-request.
I like this addition, but it should definitely be a opt-in on a per-request basis.
Setting this as default for all request is a serious security problem.
If you update the PR with this change, and fix up the tests, I'll be happy to merge it :slightly_smiling_face: 
MichaelRoosz: Hello @soofstad,
thank you for your feedback.
I have updated the pr and made sure all tests are passing.
MichaelRoosz: I updated it, now also includes a test.
MichaelRoosz: The linting check should now also be fixed.
MichaelRoosz: Hello @soofstad,
thanks a lot for your feedback, I have updated the pull-request accordingly.
I have set the default to 'same-origin', since this is the default behaviour when the 'credential' property is not set (as documented here https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) 
MichaelRoosz: thank you @soofstad , I have updated the pr :) 

Patch:
diff --git a/README.md b/README.md
index 408f7e0..42353f9 100644
--- a/README.md
+++ b/README.md
@@ -170,6 +170,12 @@ type TAuthConfig = {
   refreshTokenExpiryStrategy?: 'renewable' | 'absolute' // default: renewable
   // Whether or not to post 'scope' when refreshing the access token
   refreshWithScope?: boolean // default: true
+  tokenRequestCredentials?: RequestCredentials // default: 'same-origin'
+  // Controls whether browser credentials (cookies, TLS client certificates, or authentication headers containing a username and password) are sent when requesting tokens.
+  // Warning: The OAuth2 specification requires the client to authenticate to the token endpoint using client credentials (like client_id and client_secret) — not via cookies. Including browser credentials deviates from the standard protocol and can introduce unforeseen security issues. Only set this to 'include' if you know what you are doing and CSRF protection is present. Setting this to 'include' is required when the token endpoint requires client certificate authentication, but likely is not needed in any other case. Use with caution.
+  // - 'same-origin' (the default): only send and include credentials for same-origin requests.
+  // - 'include': always include credentials, even cross-origin.
+  // - 'omit': never send credentials in the request.
 }
 
 ```
diff --git a/src/authConfig.ts b/src/authConfig.ts
index 345a35a..1f3aa18 100644
--- a/src/authConfig.ts
+++ b/src/authConfig.ts
@@ -19,6 +19,7 @@ export function createInternalConfig(passedConfig: TAuthConfig): TInternalConfig
     storageKeyPrefix = 'ROCP_',
     refreshWithScope = true,
     refreshTokenExpiryStrategy = 'renewable',
+    tokenRequestCredentials = 'same-origin',
   }: TAuthConfig = passedConfig
 
   const config: TInternalConfig = {
@@ -34,6 +35,7 @@ export function createInternalConfig(passedConfig: TAuthConfig): TInternalConfig
     storageKeyPrefix: storageKeyPrefix,
     refreshWithScope: refreshWithScope,
     refreshTokenExpiryStrategy: refreshTokenExpiryStrategy,
+    tokenRequestCredentials: tokenRequestCredentials,
   }
   validateConfig(config)
   return config
diff --git a/src/authentication.ts b/src/authentication.ts
index cbf740b..48e8539 100644
--- a/src/authentication.ts
+++ b/src/authentication.ts
@@ -67,8 +67,12 @@ function isTokenResponse(body: unknown | TTokenResponse): body is TTokenResponse
   return (body as TTokenResponse).access_token !== undefined
 }
 
-function postTokenRequest(tokenEndpoint: string, tokenRequest: TTokenRequest): Promise<TTokenResponse> {
-  return postWithXForm(tokenEndpoint, tokenRequest).then((response) => {
+function postTokenRequest(
+  tokenEndpoint: string,
+  tokenRequest: TTokenRequest,
+  credentials: RequestCredentials
+): Promise<TTokenResponse> {
+  return postWithXForm({ url: tokenEndpoint, request: tokenRequest, credentials: credentials }).then((response) => {
     return response.json().then((body: TTokenResponse | unknown): TTokenResponse => {
       if (isTokenResponse(body)) {
         return body
@@ -106,7 +110,7 @@ export const fetchTokens = (config: TInternalConfig): Promise<TTokenResponse> =>
     // TODO: Remove in 2.0
     ...config.extraAuthParams,
   }
-  return postTokenRequest(config.tokenEndpoint, tokenRequest)
+  return postTokenRequest(config.tokenEndpoint, tokenRequest, config.tokenRequestCredentials)
 }
 
 export const fetchWithRefreshToken = (props: {
@@ -122,7 +126,7 @@ export const fetchWithRefreshToken = (props: {
     ...config.extraTokenParameters,
   }
   if (config.refreshWithScope) refreshRequest.scope = config.scope
-  return postTokenRequest(config.tokenEndpoint, refreshRequest)
+  return postTokenRequest(config.tokenEndpoint, refreshRequest, config.tokenRequestCredentials)
 }
 
 export function redirectToLogout(
diff --git a/src/httpUtils.ts b/src/httpUtils.ts
index 96d8315..786a5c1 100644
--- a/src/httpUtils.ts
+++ b/src/httpUtils.ts
@@ -9,11 +9,18 @@ function buildUrlEncodedRequest(request: TTokenRequest): string {
   return queryString
 }
 
-export async function postWithXForm(url: string, request: TTokenRequest): Promise<Response> {
+interface PostWithXFormParams {
+  url: string
+  request: TTokenRequest
+  credentials: RequestCredentials
+}
+
+export async function postWithXForm({ url, request, credentials }: PostWithXFormParams): Promise<Response> {
   return fetch(url, {
     method: 'POST',
     body: buildUrlEncodedRequest(request),
     headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    credentials: credentials,
   }).then(async (response: Response) => {
     if (!response.ok) {
       const responseBody = await response.text()
diff --git a/src/types.ts b/src/types.ts
index 86cdc7c..844b0ef 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -81,6 +81,7 @@ export type TAuthConfig = {
   storage?: 'session' | 'local'
   storageKeyPrefix?: string
   refreshWithScope?: boolean
+  tokenRequestCredentials?: RequestCredentials
 }
 
 export type TRefreshTokenExpiredEvent = {
@@ -116,4 +117,5 @@ export type TInternalConfig = {
   storage: 'session' | 'local'
   storageKeyPrefix: string
   refreshWithScope: boolean
+  tokenRequestCredentials: RequestCredentials
 }


Test patch:
diff --git a/tests/auth-util.test.ts b/tests/auth-util.test.ts
index e2b356c..d432826 100644
--- a/tests/auth-util.test.ts
+++ b/tests/auth-util.test.ts
@@ -26,6 +26,7 @@ const authConfig: TInternalConfig = {
     client_id: 'anotherClientId',
     testKey: 'test Value',
   },
+  tokenRequestCredentials: 'same-origin',
 }
 
 test('decode a JWT token', () => {
diff --git a/tests/get_token.test.tsx b/tests/get_token.test.tsx
index b9906a5..1757b47 100644
--- a/tests/get_token.test.tsx
+++ b/tests/get_token.test.tsx
@@ -19,25 +19,49 @@ global.fetch = jest.fn(() =>
   })
 )
 
-test('make token request with extra parameters', async () => {
-  // Setting up a state similar to what it would be just after redirect back from auth provider
-  localStorage.setItem('ROCP_loginInProgress', 'true')
-  localStorage.setItem('PKCE_code_verifier', 'arandomstring')
-  window.location.search = '?code=1234'
+describe('make token request', () => {
+  beforeEach(() => {
+    // Setting up a state similar to what it would be just after redirect back from auth provider
+    localStorage.setItem('ROCP_loginInProgress', 'true')
+    localStorage.setItem('PKCE_code_verifier', 'arandomstring')
+    window.location.search = '?code=1234'
+  })
+
+  test('with extra parameters', async () => {
+    render(
+      <AuthProvider authConfig={authConfig}>
+        <AuthConsumer />
+      </AuthProvider>
+    )
 
-  render(
-    <AuthProvider authConfig={authConfig}>
-      <AuthConsumer />
-    </AuthProvider>
-  )
+    await waitFor(() =>
+      expect(fetch).toHaveBeenCalledWith('myTokenEndpoint', {
+        body: 'grant_type=authorization_code&code=1234&client_id=anotherClientId&redirect_uri=http%3A%2F%2Flocalhost%2F&code_verifier=arandomstring&testTokenKey=tokenValue',
+        headers: {
+          'Content-Type': 'application/x-www-form-urlencoded',
+        },
+        method: 'POST',
+        credentials: 'same-origin',
+      })
+    )
+  })
 
-  await waitFor(() =>
-    expect(fetch).toHaveBeenCalledWith('myTokenEndpoint', {
-      body: 'grant_type=authorization_code&code=1234&client_id=anotherClientId&redirect_uri=http%3A%2F%2Flocalhost%2F&code_verifier=arandomstring&testTokenKey=tokenValue',
-      headers: {
-        'Content-Type': 'application/x-www-form-urlencoded',
-      },
-      method: 'POST',
-    })
-  )
+  test('with custom credentials', async () => {
+    render(
+      <AuthProvider authConfig={{ ...authConfig, tokenRequestCredentials: 'include' }}>
+        <AuthConsumer />
+      </AuthProvider>
+    )
+
+    await waitFor(() =>
+      expect(fetch).toHaveBeenCalledWith('myTokenEndpoint', {
+        body: 'grant_type=authorization_code&code=1234&client_id=anotherClientId&redirect_uri=http%3A%2F%2Flocalhost%2F&code_verifier=arandomstring&testTokenKey=tokenValue',
+        headers: {
+          'Content-Type': 'application/x-www-form-urlencoded',
+        },
+        method: 'POST',
+        credentials: 'include',
+      })
+    )
+  })
 })