Skip to content

Commit

Permalink
feat(tokens): access and refresh token (#7)
Browse files Browse the repository at this point in the history
* add and refactor to getATWithRefreshToken

* refactor to handle 2 types

* add delete refresh token

* add PostTokenResponse type

* add logout button, get refresh token, minor fix

* satisfy linter, tsc

* add build to ci

* fix test
  • Loading branch information
alvinsj authored Jun 9, 2024
1 parent aeee0f8 commit 2cc227e
Show file tree
Hide file tree
Showing 14 changed files with 240 additions and 80 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

name: Test

on: [push]
Expand All @@ -15,3 +14,4 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
2 changes: 1 addition & 1 deletion lib/pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
const hashed = await toSha256(codeVerifier)
const codeChallenge = toBase64Url(hashed)
return codeChallenge
Expand Down
35 changes: 28 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,59 @@ 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}`,
`code: ${code}`,
`codeVerifier: ${codeVerifier}`
] : []

const isLoggedIn = !!authContext.accessToken

return (
<AuthContext.Provider value={authContext}>
<main className={s['app']}>
<LoginButton className={s['app-loginBtn']} />
{isLoggedIn
? <LogoutButton className={s['app-loginBtn']} />
: <LoginButton className={s['app-loginBtn']} />
}
<pre>
{!isLoading && <>
<table>
Expand Down
46 changes: 40 additions & 6 deletions src/apis/__tests__/token.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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',
Expand All @@ -32,7 +36,7 @@ describe('postTokens', () => {
}))

try {
await postTokens('code', 'codeVerifier')
await postToken('')
} catch (error) {
const zodError = [{
"code": "invalid_type", "expected": "string", "received": "undefined",
Expand All @@ -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)
})
})
36 changes: 26 additions & 10 deletions src/apis/token.ts
Original file line number Diff line number Diff line change
@@ -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<PostTokenResponse> => {
const request = new Request(config.TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -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)
}
24 changes: 24 additions & 0 deletions src/components/LogoutButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={className}>
<span>
<button type="submit" onClick={handleLogout}>
Logout
</button>
</span>
</div>
)
}

export default LogoutButton
13 changes: 10 additions & 3 deletions src/contexts/AuthContext.ts
Original file line number Diff line number Diff line change
@@ -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<AuthContextType>({})
const AuthContext = createContext<AuthContextType>({
accessToken: null,
refreshToken: null,
setAccessToken: () => { },
setRefreshToken: () => { }
})

export default AuthContext
5 changes: 4 additions & 1 deletion src/hooks/__tests__/useAuthContextValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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")
})
})
2 changes: 2 additions & 0 deletions src/hooks/useAuthContextValue.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
Expand Down
Loading

0 comments on commit 2cc227e

Please sign in to comment.