Repository: soofstad/react-oauth2-pkce (ts)

Issue:
Bug: Logout does not log out on auth provider
<!--
  Please provide a clear and concise description of what the bug is. Include
  screenshots if needed. Please test using the latest version
-->

## Steps To Reproduce

1. setup logoutEndpoint
2. use logOut()
3. watch storage being cleared
4. login()
5. Doesn't redirect to authprovider/login and auto logs in

## The current behavior

Currently doesn't "log out", only clears client side info but doesn't redirect to logout endpoint.

## The expected behavior

Should log out on server and client.


Pull request description:
fix: logout function did not redirect
Fix a bug where the provided logout function did not redirect the browser to the authentication providers logout endpoint.

closes #23 


Patch:
diff --git a/README.md b/README.md
index 7838961..f7af5ce 100644
--- a/README.md
+++ b/README.md
@@ -110,10 +110,13 @@ import { AuthContext, AuthProvider } from "react-oauth2-code-pkce"
 
 ```typescript
 type TAuthConfig = {
-  // For required parameters, refer to the auth providers documentation
+  // Id of your app at the authentication provider
   clientId: string  // Required
+  // URL for the authentication endpoint at the authentication provider
   authorizationEndpoint: string  // Required
+  // URL for the token endpoint at the authentication provider
   tokenEndpoint: string  // Required
+  // Which URL the auth provider should redirect the user after loging out
   redirectUri: string  // Required
   scope?: string  // default: ''
   // Which URL to call for logging out of the auth provider
diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx
index 0e16185..d66d1c9 100644
--- a/src/AuthContext.tsx
+++ b/src/AuthContext.tsx
@@ -1,16 +1,16 @@
 import React, { createContext, useEffect, useState } from 'react' // eslint-disable-line
 import {
-  decodeJWT,
-  epochAtSecondsFromNow,
-  epochTimeIsPast,
   errorMessageForExpiredRefreshToken,
   fetchTokens,
   fetchWithRefreshToken,
+  redirectToLogout,
   redirectToLogin,
 } from './authentication'
 import useLocalStorage from './Hooks'
 import { IAuthContext, IAuthProvider, TInternalConfig, TTokenData, TTokenResponse } from './Types'
 import { validateAuthConfig } from './validateAuthConfig'
+import { epochAtSecondsFromNow, epochTimeIsPast } from './timeUtils'
+import { decodeJWT } from './decodeJWT'
 
 const FALLBACK_EXPIRE_TIME = 600 // 10minutes
 
@@ -68,6 +68,7 @@ export const AuthProvider = ({ authConfig, children }: IAuthProvider) => {
     setIdToken(undefined)
     setTokenData(undefined)
     setLoginInProgress(false)
+    if (config?.logoutEndpoint && refreshToken) redirectToLogout(config, refreshToken)
   }
 
   function login() {
diff --git a/src/Types.ts b/src/Types.ts
index 0dad440..6eabad4 100644
--- a/src/Types.ts
+++ b/src/Types.ts
@@ -6,13 +6,16 @@ interface TTokenRqBase {
   client_id: string
   redirect_uri: string
 }
+
 export interface TTokenRequestWithCodeAndVerifier extends TTokenRqBase {
   code: string
   code_verifier: string
 }
+
 export interface TTokenRequestForRefresh extends TTokenRqBase {
   refresh_token: string
 }
+
 export type TTokenRequest = TTokenRequestWithCodeAndVerifier | TTokenRequestForRefresh
 
 export type TTokenData = {
@@ -75,6 +78,7 @@ export type TInternalConfig = {
   tokenEndpoint: string
   redirectUri: string
   scope: string
+  logoutEndpoint?: string
   preLogin?: () => void
   postLogin?: () => void
   decodeToken: boolean
diff --git a/src/authentication.ts b/src/authentication.ts
index 9ffc4b2..36e232e 100644
--- a/src/authentication.ts
+++ b/src/authentication.ts
@@ -1,13 +1,13 @@
 import { generateCodeChallenge, generateRandomString } from './pkceUtils'
 import {
   TInternalConfig,
-  TTokenData,
   TAzureADErrorResponse,
   TTokenResponse,
   TTokenRequest,
   TTokenRequestWithCodeAndVerifier,
   TTokenRequestForRefresh,
 } from './Types'
+import { postWithXForm } from './httpUtils'
 
 const codeVerifierStorageKey = 'PKCE_code_verifier'
 // [ AzureAD,]
@@ -41,24 +41,8 @@ function isTokenResponse(body: TAzureADErrorResponse | TTokenResponse): body is
   return (body as TTokenResponse).access_token !== undefined
 }
 
-function buildUrlEncodedRequest(tokenRequest: TTokenRequest): string {
-  let queryString = ''
-  for (const [key, value] of Object.entries(tokenRequest)) {
-    queryString += (queryString ? '&' : '') + key + '=' + encodeURIComponent(value)
-  }
-  return queryString
-}
-
-function postWithXForm(tokenEndpoint: string, tokenRequest: TTokenRequest): Promise<TTokenResponse> {
-  return fetch(tokenEndpoint, {
-    method: 'POST',
-    body: buildUrlEncodedRequest(tokenRequest),
-    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-  }).then((response: Response) => {
-    if (!response.ok) {
-      console.error(response)
-      throw Error(response.statusText)
-    }
+function postTokenRequest(tokenEndpoint: string, tokenRequest: TTokenRequest): Promise<TTokenResponse> {
+  return postWithXForm(tokenEndpoint, tokenRequest).then((response) => {
     return response.json().then((body: TAzureADErrorResponse | TTokenResponse): TTokenResponse => {
       if (isTokenResponse(body)) {
         return body
@@ -98,7 +82,7 @@ export const fetchTokens = (config: TInternalConfig): Promise<TTokenResponse> =>
     ...config.extraAuthParams,
     ...config.extraTokenParameters,
   }
-  return postWithXForm(config.tokenEndpoint, tokenRequest)
+  return postTokenRequest(config.tokenEndpoint, tokenRequest)
 }
 
 export const fetchWithRefreshToken = (props: {
@@ -106,52 +90,26 @@ export const fetchWithRefreshToken = (props: {
   refreshToken: string
 }): Promise<TTokenResponse> => {
   const { config, refreshToken } = props
-  const tokenRequest: TTokenRequestForRefresh = {
+  const refreshRequest: TTokenRequestForRefresh = {
     grant_type: 'refresh_token',
     refresh_token: refreshToken,
     scope: config.scope,
     client_id: config.clientId,
     redirect_uri: config.redirectUri,
   }
-  return postWithXForm(config.tokenEndpoint, tokenRequest)
-}
-
-/**
- * Decodes the base64 encoded JWT. Returns a TToken.
- */
-export const decodeJWT = (token: string): TTokenData => {
-  try {
-    const base64Url = token.split('.')[1]
-    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
-    const jsonPayload = decodeURIComponent(
-      atob(base64)
-        .split('')
-        .map(function (c) {
-          return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
-        })
-        .join('')
-    )
-    return JSON.parse(jsonPayload)
-  } catch (e) {
-    console.error(e)
-    throw Error(
-      'Failed to decode the access token.\n\tIs it a proper Java Web Token?\n\t' +
-        "You can disable JWT decoding by setting the 'decodeToken' value to 'false' the configuration."
-    )
-  }
+  return postTokenRequest(config.tokenEndpoint, refreshRequest)
 }
 
-// Returns epoch time (in seconds) for when the token will expire
-export const epochAtSecondsFromNow = (secondsFromNow: number) => Math.round(Date.now() / 1000 + secondsFromNow)
-
-/**
- * Check if the Access Token has expired.
- * Will return True if the token has expired, OR there is less than 5min until it expires.
- */
-export function epochTimeIsPast(timestamp: number): boolean {
-  const now = Math.round(Date.now()) / 1000
-  const nowWithBuffer = now + 120
-  return nowWithBuffer >= timestamp
+export function redirectToLogout(config: TInternalConfig, token: string) {
+  const params = new URLSearchParams({
+    token: token,
+    // TODO: Add config param for token type
+    token_type_hint: 'refresh_token',
+    client_id: config.clientId,
+    // TODO: Add extra logout params
+    post_logout_redirect_uri: config.redirectUri,
+  })
+  window.location.replace(`${config.logoutEndpoint}?${params.toString()}`)
 }
 
 export const errorMessageForExpiredRefreshToken = (errorMessage: string): boolean => {
diff --git a/src/decodeJWT.ts b/src/decodeJWT.ts
new file mode 100644
index 0000000..b48501d
--- /dev/null
+++ b/src/decodeJWT.ts
@@ -0,0 +1,26 @@
+import { TTokenData } from './Types'
+
+/**
+ * Decodes the base64 encoded JWT. Returns a TToken.
+ */
+export const decodeJWT = (token: string): TTokenData => {
+  try {
+    const base64Url = token.split('.')[1]
+    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
+    const jsonPayload = decodeURIComponent(
+      atob(base64)
+        .split('')
+        .map(function (c) {
+          return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
+        })
+        .join('')
+    )
+    return JSON.parse(jsonPayload)
+  } catch (e) {
+    console.error(e)
+    throw Error(
+      'Failed to decode the access token.\n\tIs it a proper Java Web Token?\n\t' +
+        "You can disable JWT decoding by setting the 'decodeToken' value to 'false' the configuration."
+    )
+  }
+}
diff --git a/src/httpUtils.ts b/src/httpUtils.ts
new file mode 100644
index 0000000..cb61ebc
--- /dev/null
+++ b/src/httpUtils.ts
@@ -0,0 +1,23 @@
+import { TTokenRequest } from './Types'
+
+function buildUrlEncodedRequest(request: TTokenRequest): string {
+  let queryString = ''
+  Object.entries(request).forEach(([key, value]) => {
+    queryString += (queryString ? '&' : '') + key + '=' + encodeURIComponent(value)
+  })
+  return queryString
+}
+
+export function postWithXForm(url: string, request: TTokenRequest): Promise<Response> {
+  return fetch(url, {
+    method: 'POST',
+    body: buildUrlEncodedRequest(request),
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+  }).then((response: Response) => {
+    if (!response.ok) {
+      console.error(response)
+      throw Error(response.statusText)
+    }
+    return response
+  })
+}
diff --git a/src/index.js b/src/index.js
index 2516ad7..5d76884 100644
--- a/src/index.js
+++ b/src/index.js
@@ -7,11 +7,13 @@ const authConfig = {
   authorizationEndpoint:
     'https://login.microsoftonline.com/d422398d-b6a5-454d-a202-7ed4c1bec457/oauth2/v2.0/authorize',
   tokenEndpoint: 'https://login.microsoftonline.com/d422398d-b6a5-454d-a202-7ed4c1bec457/oauth2/v2.0/token',
-  redirectUri: 'http://localhost:3000/', // Example to redirect back to original path after login has completed
+  logoutEndpoint: 'https://login.microsoftonline.com/d422398d-b6a5-454d-a202-7ed4c1bec457/oauth2/v2.0/logout',
+  redirectUri: 'http://localhost:3000/',
   preLogin: () => localStorage.setItem('preLoginPath', window.location.pathname),
   postLogin: () => window.location.replace(localStorage.getItem('preLoginPath') || ''),
   decodeToken: true,
   scope: 'User.read',
+  autoLogin: false,
 }
 
 function LoginInfo() {
diff --git a/src/package.json b/src/package.json
index 7e53b67..b4b5ce8 100644
--- a/src/package.json
+++ b/src/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-oauth2-code-pkce",
-  "version": "1.7.0",
+  "version": "1.7.1",
   "description": "Plug-and-play react package for OAuth2 Authorization Code flow with PKCE",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
diff --git a/src/timeUtils.ts b/src/timeUtils.ts
new file mode 100644
index 0000000..99652d6
--- /dev/null
+++ b/src/timeUtils.ts
@@ -0,0 +1,12 @@
+// Returns epoch time (in seconds) for when the token will expire
+export const epochAtSecondsFromNow = (secondsFromNow: number) => Math.round(Date.now() / 1000 + secondsFromNow)
+
+/**
+ * Check if the Access Token has expired.
+ * Will return True if the token has expired, OR there is less than 5min until it expires.
+ */
+export function epochTimeIsPast(timestamp: number): boolean {
+  const now = Math.round(Date.now()) / 1000
+  const nowWithBuffer = now + 120
+  return nowWithBuffer >= timestamp
+}


Test patch:
diff --git a/tests/authentication.test.ts b/tests/authentication.test.ts
index 9569605..af0cc21 100644
--- a/tests/authentication.test.ts
+++ b/tests/authentication.test.ts
@@ -1,4 +1,5 @@
-import { decodeJWT, epochAtSecondsFromNow, epochTimeIsPast } from '../src/authentication'
+import { decodeJWT } from '../src/decodeJWT'
+import { epochAtSecondsFromNow, epochTimeIsPast } from '../src/timeUtils'
 
 test('decode a JWT token', () => {
   const tokenData = decodeJWT(