Skip to content

Commit

Permalink
feat(login): add login redirect (#5)
Browse files Browse the repository at this point in the history
* use alias

* add LoginButton, cookie handling

* get code verifier on query param

* fix styles

* add config, url found

* minor change

* moved

* update to @lib

* add test and fix

* mock cookies and fixes

* clean up

* refactor and add test

* fix test

* update test env

* linter fix
  • Loading branch information
alvinsj authored Jun 9, 2024
1 parent 2754bbb commit d0f1062
Show file tree
Hide file tree
Showing 26 changed files with 446 additions and 196 deletions.
3 changes: 3 additions & 0 deletions .env.test.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
VITE_AUTH_URL=http://test-auth-api/api/authorize
VITE_TOKEN_URL=http://test-token-api/api/oauth/token
VITE_BASE_URL=http://test-base-url
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
!.env.test.local

# Editor directories and files
.vscode/*
Expand Down
30 changes: 0 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +0,0 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:

- Configure the top-level `parserOptions` property like this:

```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```

- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Refresh Token Rotation Demo</title>
</head>
<body>
<div id="root"></div>
Expand Down
100 changes: 100 additions & 0 deletions lib/__tests__/cookie.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, test, expect, beforeAll, afterAll, vi, afterEach } from 'vitest'
import { saveCookie, getCookie, deleteCookie, clearAllCookies } from '../cookie'

describe('cookie', () => {
let mockCookie = []
beforeAll(() => {
vi.stubGlobal('document', {
get cookie() {
return mockCookie.join(';')
},
set cookie(value) {
mockCookie = mockCookie.filter(c => !c.startsWith(value.split('=')[0]))
mockCookie.push(value)
},
})
})

afterEach(() => {
mockCookie = []
})

afterAll(() => {
vi.unstubAllGlobals()
})

describe('saveCookie', () => {
test('throws error on empty name', () => {
expect(() => saveCookie()).toThrowError('Cookie name is required')
})

test('saves empty value', () => {
saveCookie('a', '')
expect(document.cookie).toMatch('a=;')
})

test('saves cookie', () => {
saveCookie('a', '1234')
expect(document.cookie).toMatch('a=1234;')
})

test('saves cookie with latest value', () => {
saveCookie('a', '1234')
saveCookie('a', '124')
expect(document.cookie).toMatch('a=124;')
expect(document.cookie).not.toMatch('a=1234;')
})
})

describe('getCookie', () => {
test('gets none', () => {
saveCookie('b', '54321')
expect(getCookie('c')).toBeUndefined()
})

test('gets cookie', () => {
saveCookie('b', '54321')
expect(getCookie('b')).toBe('54321')
})
})

describe('deleteCookie', () => {
test('deletes cookie', () => {
saveCookie('a', '12345')
expect(document.cookie).toMatch('a=12345;')

deleteCookie('a')
expect(document.cookie).toMatch('a=;')
})

test('deletes cookie without affecting others', () => {
saveCookie('a', '12345')
expect(document.cookie).toMatch('a=12345;')

saveCookie('b', '54321')
expect(document.cookie).toMatch('b=54321;')

deleteCookie('a')
expect(document.cookie).toMatch('a=;')
expect(document.cookie).toMatch('b=54321;')
})

test('deletes none', () => {
saveCookie('a', '12345')
saveCookie('b', '54321')
deleteCookie('c')
expect(document.cookie).toMatch('a=12345;')
expect(document.cookie).toMatch('b=54321;')
})
})

describe('clearAllCookies', () => {
test('clears all cookies', () => {
saveCookie('a', '12345')
saveCookie('b', '54321')
clearAllCookies()
expect(document.cookie).toMatch('a=;')
expect(document.cookie).toMatch('b=;')
})
})
})
File renamed without changes.
30 changes: 30 additions & 0 deletions lib/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export const saveCookie = (name: string, value: string, mins: number = 60) => {
if (!name)
throw new Error('Cookie name is required')

const date = new Date()
date.setTime(date.getTime() + mins * 60 * 1000)
document.cookie =
`${name}=${value};Expires=${date.toUTCString()}; \
path=/; Secure; SameSite=Strict`
}

export const getCookie = (name: string) => {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) return parts.pop()?.split(';').shift()
}

export const deleteCookie = (name: string) => {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`
}

export const clearAllCookies = () => {
const cookies = document.cookie.split(";")
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i]
const eqPos = cookie.indexOf("=")
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT`
}
}
File renamed without changes.
42 changes: 0 additions & 42 deletions src/App.css

This file was deleted.

2 changes: 1 addition & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ test('renders text', () => {
expect(wrapper).toBeTruthy()

const { getByText } = wrapper
expect(getByText('Vite + React')).toBeTruthy()
expect(getByText('Login')).toBeTruthy()
})
55 changes: 30 additions & 25 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import { useEffect, useState } from 'react'

import { getPKCEStatus } from '@/utils/auth'
import LoginButton from '@/components/LoginButton'
import { clearAllCookies } from '@lib/cookie'

function App() {
const [count, setCount] = useState(0)
const params = new URLSearchParams(window.location.search)
const state = params.get('state')
const code = params.get('code')

const [isAuthReady, setIsAuthReady] = useState(false)
const [status, setStatus] = useState('')

useEffect(() => {
if (state) {
const { isDone: isAuthReady, codeVerifier } =
getPKCEStatus(state)

if (isAuthReady) {
setIsAuthReady(isAuthReady)
setStatus(`code: ${code}, codeVerifier: ${codeVerifier}`)
}
}

return () => clearAllCookies()
// on mount only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
{isAuthReady && <p>Ready for Auth Request</p>}
{status && <p>{status}</p>}
<LoginButton />
</>
)
}
Expand Down
21 changes: 21 additions & 0 deletions src/components/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import useInitPKCE from '@/hooks/useInitPKCE'

const LoginButton = () => {
const { error, onLogin } = useInitPKCE()

return (
<>
<button type="submit" onClick={onLogin}>
Login
</button>
<pre>
{error}
</pre>
<pre>
{document.cookie}
</pre>
</>
)
}

export default LoginButton
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const BASE_URL = import.meta.env.VITE_BASE_URL
const LOGIN_URL = import.meta.env.VITE_AUTH_URL
const TOKEN_URL = import.meta.env.VITE_TOKEN_URL

const STATE_COOKIE_PREFIX = "app.txs."

export default {
BASE_URL,
LOGIN_URL,
TOKEN_URL,

STATE_COOKIE_PREFIX
}
39 changes: 39 additions & 0 deletions src/hooks/__tests__/useInitPKCE.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { beforeAll, expect, describe, test, vi, afterAll } from 'vitest'
import { renderHook } from '@testing-library/react'

import useInitPKCE from '../useInitPKCE'
import config from '@/config'

describe('useInitPKCE', () => {
const redirect = vi.fn()
beforeAll(() => {
vi.stubGlobal('location', { replace: redirect })
})

afterAll(() => {
vi.unstubAllGlobals()
})

test('returns empty error and onLogin', () => {
const { result } = renderHook(() => useInitPKCE())
expect(result.current).toMatchObject({
error: '',
onLogin: expect.any(Function),
})
})

test('redirects to login url', async () => {
const { result } = renderHook(() => useInitPKCE())
await result.current.onLogin()

expect(redirect).toHaveBeenCalled()
const url = redirect.mock.calls[0][0]
const query = new URLSearchParams(url.split('?')[1])

expect(query.get('response_type')).toBe('code,id_token')
expect(query.get('redirect_uri')).toBe(config.BASE_URL)
expect(query.get('state')).toEqual(expect.any(String))
expect(query.get('code_challenge')).toEqual(expect.any(String))
})
})

21 changes: 21 additions & 0 deletions src/hooks/useInitPKCE.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useCallback, useState } from 'react'
import { createPKCECodes, redirectToLogin } from '@/utils/auth'

const useInitPKCE = () => {
const [error, setError] = useState('')

const onLogin = useCallback(async () => {
try {
const codes = await createPKCECodes()
redirectToLogin(codes.state, codes.codeChallenge)
} catch (error) {
if (error instanceof Error)
setError(error.message)
else setError('An unknown error occurred')
}
}, [])

return { error, onLogin }
}

export default useInitPKCE
Loading

0 comments on commit d0f1062

Please sign in to comment.