Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tokens): access and refresh token #7

Merged
merged 8 commits into from
Jun 9, 2024
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
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