diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6909961..7f7266a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,3 @@ - name: Test on: [push] @@ -15,3 +14,4 @@ jobs: - run: npm ci - run: npm run lint - run: npm run test + - run: npm run build diff --git a/lib/pkce.ts b/lib/pkce.ts index e5759b3..cfa4450 100644 --- a/lib/pkce.ts +++ b/lib/pkce.ts @@ -35,7 +35,7 @@ export const createRandomString = (length: number = 34): string => { return randomString } -export const createPKCECodeChallenge = async (codeVerifier: string): string => { +export const createPKCECodeChallenge = async (codeVerifier: string): Promise => { const hashed = await toSha256(codeVerifier) const codeChallenge = toBase64Url(hashed) return codeChallenge diff --git a/src/App.tsx b/src/App.tsx index 62c1d9a..a2d1009 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,27 +7,43 @@ import AuthContext from '@/contexts/AuthContext' import LoginButton from '@/components/LoginButton' import useAuthContextValue from '@/hooks/useAuthContextValue' -import useGetAccessTokenEffect from '@/hooks/useGetAccessTokenEffect' +import useGetAccessToken from '@/hooks/useGetAccessToken' import s from './App.module.css' +import LogoutButton from './components/LogoutButton' function App() { const params = new URLSearchParams(window.location.search) const state = params.get('state') const code = params.get('code') + const { codeVerifier } = getPKCEStatus(state) - const { isLoading, error, tokens } = useGetAccessTokenEffect( - state, code, codeVerifier - ) + + const { + isLoading, error, tokens, + getATWithAuthCode, getATWithRefreshToken + } = useGetAccessToken() const authContext = useAuthContextValue(tokens) useEffect(() => { - if (state && codeVerifier && tokens?.accessToken && tokens?.refreshToken) { + if (authContext.refreshToken && !authContext.accessToken) { + getATWithRefreshToken(authContext.refreshToken) + } else if (code && codeVerifier) { + getATWithAuthCode(code, codeVerifier) + } + // once on mount only + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const justDoneAuthCodeRequest = + state && codeVerifier && tokens?.accessToken && tokens?.refreshToken + useEffect(() => { + if (justDoneAuthCodeRequest) { deleteStateCookie(state) window.history.replaceState({}, document.title, '/') } - }, [tokens, codeVerifier, state]) + }, [justDoneAuthCodeRequest, state]) const status = state && code && codeVerifier ? [ `state: ${state}`, @@ -35,10 +51,15 @@ function App() { `codeVerifier: ${codeVerifier}` ] : [] + const isLoggedIn = !!authContext.accessToken + return (
- + {isLoggedIn + ? + : + }
           {!isLoading && <>
             
diff --git a/src/apis/__tests__/token.test.ts b/src/apis/__tests__/token.test.ts
index b321372..aac66f8 100644
--- a/src/apis/__tests__/token.test.ts
+++ b/src/apis/__tests__/token.test.ts
@@ -1,8 +1,12 @@
-import { describe, test, expect, vi } from 'vitest'
-import { postTokens } from '../token'
+import { describe, test, expect, vi, afterEach } from 'vitest'
+import { postToken, postTokenWithAuthCode, postTokenWithRefreshToken } from '../token'
 import { ZodError } from 'zod'
 
-describe('postTokens', () => {
+describe('postToken', () => {
+  afterEach(() => {
+    vi.unstubAllGlobals()
+  })
+
   test('returns access and refresh tokens', async () => {
     vi.stubGlobal('fetch', () => Promise.resolve({
       ok: true,
@@ -13,7 +17,7 @@ describe('postTokens', () => {
       })
     }))
 
-    const res = await postTokens('code', 'codeVerifier')
+    const res = await postToken('')
     expect(res).toEqual({
       access_token: 'access_token_from_server',
       refresh_token: 'refresh_token_from_server',
@@ -32,7 +36,7 @@ describe('postTokens', () => {
     }))
 
     try {
-      await postTokens('code', 'codeVerifier')
+      await postToken('')
     } catch (error) {
       const zodError = [{
         "code": "invalid_type", "expected": "string", "received": "undefined",
@@ -53,9 +57,39 @@ describe('postTokens', () => {
     }))
 
     try {
-      await postTokens('code', 'codeVerifier')
+      await postToken('')
     } catch (error) {
       expect(error).toEqual(new Error('Bad Request'))
     }
   })
+
+  test('postTokenWithAuthCode', async () => {
+    const mockResponse = {
+      access_token: 'access_token_from_server',
+      refresh_token: 'refresh_token_from_server',
+      expires_at: 1234567890
+    }
+    vi.stubGlobal('fetch', () => Promise.resolve({
+      ok: true,
+      json: () => Promise.resolve(mockResponse)
+    }))
+    const res = await postTokenWithAuthCode("code", "code_verifier")
+
+    expect(res).toEqual(mockResponse)
+  })
+
+  test('postTokenWithRefreshToken', async () => {
+    const mockResponse = {
+      access_token: 'access_token_from_server',
+      refresh_token: 'refresh_token_from_server',
+      expires_at: 1234567890
+    }
+    vi.stubGlobal('fetch', () => Promise.resolve({
+      ok: true,
+      json: () => Promise.resolve(mockResponse)
+    }))
+    const res = await postTokenWithRefreshToken("refresh_token_jwt")
+
+    expect(res).toEqual(mockResponse)
+  })
 })
diff --git a/src/apis/token.ts b/src/apis/token.ts
index 61c99ac..aaf1087 100644
--- a/src/apis/token.ts
+++ b/src/apis/token.ts
@@ -1,14 +1,13 @@
 import config from "@/config"
-import { PostTokenSchema } from "@/types"
+import { PostTokenResponse, PostTokenSchema } from "@/types"
 
+const nullTokenResponse: PostTokenResponse = {
+  access_token: '',
+  expires_at: 0,
+  refresh_token: ''
+}
 let abortController: AbortController | undefined
-export const postTokens = async (code: string, codeVerifier: string) => {
-  const body = JSON.stringify({
-    code,
-    code_verifier: codeVerifier,
-    grant_type: 'authorization_code'
-  })
-
+export const postToken = async (body: string): Promise => {
   const request = new Request(config.TOKEN_URL, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
@@ -32,10 +31,27 @@ export const postTokens = async (code: string, codeVerifier: string) => {
   } catch (error) {
 
     if (typeof error === 'string' && error === 'Abort the previous request')
-      return {}
+      return nullTokenResponse
 
     if (error instanceof Error) throw error
 
-    throw new Error('postTokens - An unknown error occurred')
+    throw new Error('postToken - An unknown error occurred')
   }
 }
+
+export const postTokenWithRefreshToken = async (refreshToken: string) => {
+  const body = JSON.stringify({
+    grant_type: 'refresh_token',
+    refresh_token: refreshToken
+  })
+  return postToken(body)
+}
+
+export const postTokenWithAuthCode = async (code: string, codeVerifier: string) => {
+  const body = JSON.stringify({
+    code,
+    code_verifier: codeVerifier,
+    grant_type: 'authorization_code'
+  })
+  return postToken(body)
+}
diff --git a/src/components/LogoutButton.tsx b/src/components/LogoutButton.tsx
new file mode 100644
index 0000000..e79c1ab
--- /dev/null
+++ b/src/components/LogoutButton.tsx
@@ -0,0 +1,24 @@
+import { deleteRefreshToken } from "@/utils/token"
+import { useCallback } from "react"
+
+type LogoutButtonProps = {
+  className?: string
+}
+const LogoutButton = ({ className }: LogoutButtonProps) => {
+  const handleLogout = useCallback(() => {
+    deleteRefreshToken()
+    window.location.reload()
+  }, [])
+
+  return (
+    
+ + + +
+ ) +} + +export default LogoutButton diff --git a/src/contexts/AuthContext.ts b/src/contexts/AuthContext.ts index 0b235d7..d4c1d5c 100644 --- a/src/contexts/AuthContext.ts +++ b/src/contexts/AuthContext.ts @@ -1,10 +1,17 @@ import { createContext } from "use-context-selector" export type AuthContextType = { - accessToken?: string, - refreshToken?: string | null, + readonly accessToken: string | null; + readonly refreshToken: string | null; + setAccessToken(token: string): void; + setRefreshToken(token: string): void; } -const AuthContext = createContext({}) +const AuthContext = createContext({ + accessToken: null, + refreshToken: null, + setAccessToken: () => { }, + setRefreshToken: () => { } +}) export default AuthContext diff --git a/src/hooks/__tests__/useAuthContextValue.test.ts b/src/hooks/__tests__/useAuthContextValue.test.ts index cbead3b..4e1b16b 100644 --- a/src/hooks/__tests__/useAuthContextValue.test.ts +++ b/src/hooks/__tests__/useAuthContextValue.test.ts @@ -5,7 +5,10 @@ import useAuthContextValue from "@/hooks/useAuthContextValue" describe("useAuthContextValue", () => { test("returns auth context", () => { - const { result } = renderHook(() => useAuthContextValue({})) + const { result } = renderHook(() => useAuthContextValue({ + accessToken: null, + refreshToken: null + })) expect(result.current).toEqual({ accessToken: null, refreshToken: null, diff --git a/src/hooks/__tests__/useGetAccessTokenEffect.test.ts b/src/hooks/__tests__/useGetAccessToken.test.ts similarity index 50% rename from src/hooks/__tests__/useGetAccessTokenEffect.test.ts rename to src/hooks/__tests__/useGetAccessToken.test.ts index 8b197d2..926e381 100644 --- a/src/hooks/__tests__/useGetAccessTokenEffect.test.ts +++ b/src/hooks/__tests__/useGetAccessToken.test.ts @@ -1,28 +1,37 @@ import { describe, test, expect, vi } from "vitest" -import { renderHook } from "@testing-library/react" -import useGetAccessTokenEffect from "@/hooks/useGetAccessTokenEffect" +import { act, renderHook } from "@testing-library/react" +import useGetAccessToken from "@/hooks/useGetAccessToken" -describe("useGetAccessTokenEffect", () => { +describe("useGetAccessToken", () => { test("returns null state", () => { - const { result } = renderHook(() => useGetAccessTokenEffect(null, null, undefined)) + const { result } = renderHook(() => useGetAccessToken()) expect(result.current).toEqual({ + getATWithAuthCode: expect.any(Function), + getATWithRefreshToken: expect.any(Function), isLoading: false, error: null, - tokens: null + tokens: { + accessToken: null, + refreshToken: null, + } }) }) test("returns access tokens", async () => { vi.mock("@/apis/token", async () => ({ - postTokens: vi.fn(() => Promise.resolve({ + postToken: vi.fn(() => Promise.resolve({ refresh_token: "refresh_token_jwt", access_token: "access_token_jwt" })) })) const { result } = renderHook( - () => useGetAccessTokenEffect("state", "code", "codeVerifier") + () => useGetAccessToken() ) + act(() => { + result.current.getATWithAuthCode("code", "codeVerifier") + }) + expect(result.current.isLoading).toBeTruthy() expect(result.current.error).toBeFalsy() @@ -34,18 +43,31 @@ describe("useGetAccessTokenEffect", () => { expect(result.current.isLoading).toBeFalsy() expect(result.current.error).toBeFalsy() }) + + vi.unmock("@/apis/token") }) test("captures error", async () => { vi.mock("@/apis/token", async () => ({ - postTokens: vi.fn(() => Promise.reject(new Error("error"))) + postToken: vi.fn(() => Promise.reject(new Error("error"))) })) const { result } = renderHook(() => - useGetAccessTokenEffect("state", "code", "codeVerifier")) + useGetAccessToken() + ) + + act(() => { + result.current.getATWithAuthCode("code", "codeVerifier") + }) expect(result.current.isLoading).toBeTruthy() expect(result.current.error).toBeNull() - await vi.waitFor(() => expect(result.current.error).toBe("error")) + + vi.waitFor(() => { + expect(result.current.tokens).toBeNull() + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBe("error") + }) + vi.unmock("@/apis/token") }) }) diff --git a/src/hooks/useAuthContextValue.ts b/src/hooks/useAuthContextValue.ts index 579f681..183fc33 100644 --- a/src/hooks/useAuthContextValue.ts +++ b/src/hooks/useAuthContextValue.ts @@ -1,5 +1,7 @@ import { useState, useMemo, useEffect } from 'react' + import { getRefreshToken, setRefreshToken } from '@/utils/token' +import { AuthTokens } from '@/types' const useAuthContextValue = (tokens: AuthTokens) => { const [accessToken, setAccessToken] = useState(null) diff --git a/src/hooks/useGetAccessToken.ts b/src/hooks/useGetAccessToken.ts new file mode 100644 index 0000000..33dfcb0 --- /dev/null +++ b/src/hooks/useGetAccessToken.ts @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react' + +import { postTokenWithAuthCode, postTokenWithRefreshToken } from '@/apis/token' +import { AuthTokens, PostTokenResponse } from '@/types' + +const useGetAccessToken = () => { + const [isLoading, setIsLoading] = useState(false) + const [tokens, setTokens] = useState({ + accessToken: null, + refreshToken: null + }) + const [error, setError] = useState(null) + + const getATWithAuthCode = useCallback(async ( + code: string, + codeVerifier: string + ) => { + setIsLoading(true) + setError(null) + await postTokenWithAuthCode(code, codeVerifier!) + .then((res: PostTokenResponse) => { + setTokens({ + accessToken: res.access_token, + refreshToken: res.refresh_token + }) + }) + .catch((err) => + setError( + err?.message + ?? 'getATWithAuthCode - An unknown error occurred' + ) + ) + .finally(() => { + setIsLoading(false) + }) + }, []) + + // FIXME refactor duplicated code + const getATWithRefreshToken = useCallback(async (refreshToken: string) => { + setIsLoading(true) + setError(null) + await postTokenWithRefreshToken(refreshToken) + .then((res: PostTokenResponse) => { + setTokens({ + accessToken: res.access_token, + refreshToken: res.refresh_token + }) + }) + .catch((err) => + setError( + err?.message + ?? 'getATWithRefreshToken - An unknown error occurred' + ) + ) + .finally(() => { + setIsLoading(false) + }) + }, []) + + return { + isLoading, error, tokens, + getATWithAuthCode, getATWithRefreshToken + } +} + +export default useGetAccessToken diff --git a/src/hooks/useGetAccessTokenEffect.ts b/src/hooks/useGetAccessTokenEffect.ts deleted file mode 100644 index cab0bf7..0000000 --- a/src/hooks/useGetAccessTokenEffect.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useState } from 'react' - -import { postTokens } from '@/apis/token' - -const useGetAccessTokenEffect = ( - state: string | null, - code: string | null, - codeVerifier: string | undefined -) => { - const [isLoading, setIsLoading] = useState(false) - const [tokens, setTokens] = useState<{ accessToken: string, refreshToken: string } | null>(null) - const [error, setError] = useState(null) - - useEffect(() => { - if (state && code && codeVerifier) { - setIsLoading(true) - setError(null) - postTokens(code, codeVerifier!) - .then((res) => setTokens({ - accessToken: res.access_token, - refreshToken: res.refresh_token - })) - .catch((err) => - setError( - err?.message - ?? 'useGetAccessTokenEffect - An unknown error occurred' - ) - ) - .finally(() => { - setIsLoading(false) - }) - } - - // on mount only - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return { isLoading, error, tokens } -} - -export default useGetAccessTokenEffect diff --git a/src/types/index.ts b/src/types/index.ts index e97b61b..dfb9391 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,3 +15,5 @@ export const PostTokenSchema = z.object({ expires_at: z.number(), refresh_token: z.string(), }) + +export type PostTokenResponse = z.infer diff --git a/src/utils/token.ts b/src/utils/token.ts index d232986..d893061 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -7,3 +7,7 @@ export const getRefreshToken = () => { return localStorage.getItem('refresh_token') } +export const deleteRefreshToken = () => { + localStorage.removeItem('refresh_token') +} +