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

Final version. #1

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
357fef8
Add necessary dependencies
Nsquik Feb 25, 2021
71f60c6
Add eslint + prettier + tsconfig etc.
Nsquik Feb 25, 2021
f447a0c
ADD tailwind config
Nsquik Feb 25, 2021
4bfbf40
Add visual components
Nsquik Feb 25, 2021
bd90cbd
Add login + register skeletons
Nsquik Feb 25, 2021
464f79e
Add login proxy path
Nsquik Mar 3, 2021
0bc90b5
Add refreshToken proxy path
Nsquik Mar 3, 2021
68115bf
Add logout proxy path
Nsquik Mar 3, 2021
784b159
Add register proxy path
Nsquik Mar 3, 2021
5ab61f1
Add frogs proxy path
Nsquik Mar 3, 2021
ce8dde0
ADD axios interceptor
Nsquik Mar 3, 2021
78f3084
Init auth slice
Nsquik Mar 4, 2021
5b07689
Init frogs slice
Nsquik Mar 4, 2021
80e5d63
Init async actions
Nsquik Mar 4, 2021
057484b
Init store.ts file with next-redux-wrapper
Nsquik Mar 4, 2021
8834edd
Wrapp app with provider
Nsquik Mar 4, 2021
6be14fa
improve types
Nsquik Mar 4, 2021
3ea65a8
Fix redux types
Nsquik Mar 4, 2021
f8e72ae
Init authorize HoF
Nsquik Mar 4, 2021
9ad208a
Fix types
Nsquik Mar 4, 2021
374b03d
fix bug
Nsquik Mar 4, 2021
1a99bd1
Merge branch 'step1-proxy' of https://github.com/Nsquik/next-jwt-auth…
Nsquik Mar 4, 2021
d74fa16
Merge branch 'step1-proxy' of https://github.com/Nsquik/next-jwt-auth…
Nsquik Mar 4, 2021
41a3ceb
Merge branch 'step2-interceptor' of https://github.com/Nsquik/next-jw…
Nsquik Mar 4, 2021
063efaa
Merge branch 'step3-redux' of https://github.com/Nsquik/next-jwt-auth…
Nsquik Mar 4, 2021
118e626
Add authorize HoF
Nsquik Mar 5, 2021
eeed82d
Fix bugs in slices
Nsquik Mar 5, 2021
8dfd558
Merge branch 'step3-redux' of https://github.com/Nsquik/next-jwt-auth…
Nsquik Mar 5, 2021
50e21d3
Fix little bugs with redux + add AuthGuard component
Nsquik Mar 5, 2021
a207f33
use user() HoF in index.ts
Nsquik Mar 5, 2021
44ac9b7
Merge branch 'step4-HoF' of https://github.com/Nsquik/next-jwt-auth i…
Nsquik Mar 5, 2021
f29690b
Fix redux type issue + add AuthGuard to index.tsx
Nsquik Mar 5, 2021
e761e88
Add register/logout/login
Nsquik Mar 5, 2021
77724ae
Fix typo
Nsquik Mar 5, 2021
2f07306
cleanup code
Nsquik Mar 5, 2021
bf89c1f
little fixes
Nsquik Apr 29, 2021
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
15 changes: 15 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"presets": ["next/babel"],
"plugins": [
"babel-plugin-macros",
[
"styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": true
}
]
]
}

11 changes: 11 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "eslint-config-twg/typescript.js",
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"no-shadow": "off",
"prettier/prettier": ["error", { "endOfLine": "auto" }]
}
}

9 changes: 9 additions & 0 deletions babel-plugin-macros.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
twin: {
config: 'tailwind.config.js',
preset: 'styled-components',
dataTwProp: true,
debugPlugins: false,
debug: false,
},
}
35 changes: 35 additions & 0 deletions components/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { OurStore } from '../lib/store'

type Props = {
readonly role?: 'admin'
readonly customText?: React.ReactNode
}

export const AuthGuard: React.FC<Props> = ({ children, role, customText }) => {
const { loading, me } = useSelector((state: OurStore) => state.authReducer)

if (loading === 'loading') {
return <>loading...</>
}

// Without role allow all authorized users
if (me) {
return <>{children}</>
}

if (role === 'admin' && me?.role === 'ADMIN') {
return <>{children}</>
}

return (
<section>
<h2 className="text-center">Unauthorized</h2>
<div className="text-center">
{customText ||
"You don't have permission to access this page. Pleae contact an admin if you think something is wrong."}
</div>
</section>
)
}
30 changes: 30 additions & 0 deletions components/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import styled from 'styled-components'
import React from 'react'

export const StyledButton = styled.button`
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(1);
}
&:focus {
box-shadow: 0px 0px 0px 3px rgba(0, 0, 0, 0.1);
}
`

export interface Props {
className?: string
onClick?: () => void
}

const Button: React.FC<Props> = ({ children, className, onClick }) => (
<StyledButton
onClick={onClick}
type="submit"
className={`bg-primary text-white rounded-xl w-max self-center py-2 px-4 focus:outline-none transition shadow-sm-light ${className}`}>
{children}
</StyledButton>
)

export default Button
42 changes: 42 additions & 0 deletions components/FormWithLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react'
import styled from 'styled-components'
import Button from './Button'

export interface Props {
onSubmit: () => void
onRedirect: () => void
topText: string
redirectText: string
buttonText: string
}

export const StyledButton = styled.button`
&:hover {
transform: scale(1.1);
}
&:focus {
border: 1px solid #236645;
}
`

const FormWithLabel: React.FC<Props> = ({
children,
onSubmit,
topText,
buttonText,
onRedirect,
redirectText,
}) => (
<form
onSubmit={onSubmit}
className="bg-white flex flex-col p-4 w-1/4 rounded-xl flex flex-col shadow-sm-light">
<p className="self-center text-xl font-bold mb-2">{topText}</p>
{children}
<Button>{buttonText}</Button>
<div className="text-primary underline cursor-pointer self-center mt-2" onClick={onRedirect}>
{redirectText}
</div>
</form>
)

export default FormWithLabel
26 changes: 26 additions & 0 deletions components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useRouter } from 'next/dist/client/router'
import React from 'react'
import { useDispatch } from 'react-redux'
import { logout } from '../lib/slices/auth'
import { MyThunkDispatch } from '../lib/store'

import Button from './Button'

export const Header: React.FC = ({ children }) => {
const router = useRouter()
const dispatch: MyThunkDispatch = useDispatch()

return (
<div>
<Button
className="bg-primary absolute rounded-xl p-2 text-white shadow-xl-light left-4 top-4"
onClick={async () => {
await dispatch(logout())
router.push('/login')
}}>
Logout
</Button>
{children}
</div>
)
}
36 changes: 36 additions & 0 deletions components/InputWithError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { FormikHandlers, FormikProps, FormikValues } from 'formik'
import React from 'react'
import styled from 'styled-components'

export interface Props {
formik: FormikProps<FormikValues>
name: string
onChange?: FormikHandlers['handleChange']
label?: string
type?: string
}

export const StyledInput = styled.input.attrs({
className: `transition-colors shadow-xl-light duration-500 border-solid border-transparent w-full bg-ice-blue text-14 mb-1 font-medium rounded-xl px-5 h-12 mb-0 text-slate-gray focus:outline-none focus:border-primary placeholder-light-gray-blue
`,
})`
border-width: 1px;
`

const InputWithError: React.FC<Props> = ({
label,
formik: { values, errors, touched, handleChange },
name,
onChange = handleChange,
...rest
}) => (
<div className="flex flex-col mb-2 text-light-black text-12 font-medium tracking-1px ">
<label className="ml-1 uppercase" htmlFor={name}>
{label}
</label>
<StyledInput type="text" value={values[name]} onChange={onChange} name={name} {...rest} />
<p className="text-red-600 ml-1 h-4">{touched[name] && errors[name]}</p>
</div>
)

export default InputWithError
11 changes: 11 additions & 0 deletions components/Logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react'
import Image from 'next/image'

const Logo = () => (
<div className="flex flex-col mb-10">
<Image src="/frog1.png" alt="me" width="64" height="64" />
<p className="text-lg">Frog Auth</p>
</div>
)

export default Logo
123 changes: 123 additions & 0 deletions lib/authorize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { AnyAction, Store } from '@reduxjs/toolkit'
import { ServerResponse } from 'http'
import { GetServerSidePropsContext } from 'next'
import * as setCookie from 'set-cookie-parser'
import * as cookie from 'cookie'
import axios from './axios'
import { fetchUser, reset, updateAccessToken } from './slices/auth'
import { OurStore, MyThunkDispatch, wrapper } from './store'

export type ContextWithStore = Omit<
GetServerSidePropsContext & {
store: Store<OurStore, AnyAction>
},
'resolvedUrl'
>

export type Callback = (
accessToken: string,
store: Store<OurStore, AnyAction>,
res: ServerResponse
) => Record<string, unknown> | Promise<Record<string, unknown>>

interface AuthorizeProps {
context: ContextWithStore
callback: Callback
}

export const authorize = async ({ context, callback }: AuthorizeProps) => {
const { store, req, res } = context
const { dispatch }: { dispatch: MyThunkDispatch } = store // get dispatch action
const { accessToken } = store.getState().authReducer // get accessToken from memory - redux.
if (req) {
// 1. We take cookies (refresh_token) from the client's browser and set it as ours (server-side)
axios.defaults.headers.cookie = req.headers.cookie || null

// 1a. If accessToken exists assign it as Authorization token in our axios instance.
if (accessToken) axios.defaults.headers.Authorization = `Bearer ${accessToken}`

if (!accessToken) {
// No accessToken path:
// You're probably revisiting the size cuz the accessToken
// is not available in the server store. Or you refreshed the page.
// In that case we need to go through the refresh token process.
try {
// 1. Call our proxy api refresh token route. Try to refresh the token
// using the refresh token assigned in step 1 (global).
const response = await axios.get('/api/refreshToken')
const newAccessToken = response.data.accessToken
// 2. We got new set of cookies from the response.
// 2a. Parse the refreshToken cookie using 'set-cookie-parser' library
const responseCookie = setCookie.parse(response.headers['set-cookie'])[0]
// 3. Set a fresh cookie header for our axios instance.
axios.defaults.headers.cookie = cookie.serialize(responseCookie.name, responseCookie.value)
// 4. Set a fresh Authorization header for our axios instance.
axios.defaults.headers.Authorization = `Bearer ${newAccessToken}`
// 5. Update the client's refresh token
res.setHeader('set-cookie', response.headers['set-cookie'])
// 6. And last step => update server's redux store accessToken
dispatch(updateAccessToken({ token: newAccessToken }))
} catch (error) {
// Handle error case. The most possible error would be
// axios.get('/api/refreshToken) failing
// that would mean our refreshToken has expired or
// it is simply wrong. So let's reset our auth slice.
// So we get logged out :)
store.dispatch(reset())
return null
}
}

// Now we should be ready to call the authorized endpoint we want.
// Disclaimer: its still not 100% sure that we will be able to get the resources
// Our accessToken we got in line 31 could be INVALID / EXPIRED !
try {
// 1. We call the callback. ( some callback with api calls - for example fetching list of frogs )
// If it fails axios interceptor defined in axios.ts will fire
// will try to refresh the token for us and call that action once again.
const cbResponse = await callback(accessToken, store, res)
if (axios.defaults.headers.setCookie) {
// 2. Optional
// If callback fired refreshing the token
// then the interceptor set a helper header (see axios.ts file)
// 2a. that we will use to update the client's refreshToken.
res.setHeader('set-cookie', axios.defaults.headers.setCookie)
// 2b. We also update the accessToken
dispatch(updateAccessToken({ token: axios.defaults.headers.Authorization.split(' ')[1] }))
// 2c. Then we clean up the header.
delete axios.defaults.headers.setCookie
}
// 3. We return response.
return cbResponse
} catch (e) {
// We're here when axios interceptor fails to refresh the token.
// Here we should handle handling/ indicating that the user is not authorized.
store.dispatch(reset())
return null
}
}
}

interface UserProps {
callback: Callback
}

export const user = ({ callback }: UserProps) =>
// 1. We use wrapper from next-wrapper-redux library to wrap our gerServerSideProps
// with our redux store.
// property "context" contains store
wrapper.getServerSideProps(async (context: ContextWithStore) => {
const { dispatch }: { dispatch: MyThunkDispatch } = context.store
// 2. Call our authorize Higher order Function
return authorize({
context,
callback: async (...props) => {
// 3. If we currently don't have our user fetched
// Then we're not authorized.
// So try to fetch the user.
if (!context.store.getState().authReducer.me) await dispatch(fetchUser())
// 4. return the response from the callback
return callback(...props)
},
})
})
40 changes: 40 additions & 0 deletions lib/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import * as cookie from 'cookie'
import * as setCookie from 'set-cookie-parser'

// Create axios instance.
const axiosInstance = axios.create({
baseURL: 'http://localhost:3000',
withCredentials: true,
})

// Create axios interceptor
createAuthRefreshInterceptor(axiosInstance, (failedRequest) =>
// 1. First try request fails - refresh the token.
axiosInstance.get('/api/refreshToken').then((resp) => {
// 1a. Clear old helper cookie used in 'authorize.ts' higher order function.
if (axiosInstance.defaults.headers.setCookie) {
delete axiosInstance.defaults.headers.setCookie
}
const { accessToken } = resp.data
// 2. Set up new access token
const bearer = `Bearer ${accessToken}`
axiosInstance.defaults.headers.Authorization = bearer

// 3. Set up new refresh token as cookie
const responseCookie = setCookie.parse(resp.headers['set-cookie'])[0] // 3a. We can't just acces it, we need to parse it first.
axiosInstance.defaults.headers.setCookie = resp.headers['set-cookie'] // 3b. Set helper cookie for 'authorize.ts' Higher order Function.
axiosInstance.defaults.headers.cookie = cookie.serialize(
responseCookie.name,
responseCookie.value
)
// 4. Set up access token of the failed request.
failedRequest.response.config.headers.Authorization = bearer

// 5. Retry the request with new setup!
return Promise.resolve()
})
)

export default axiosInstance
Loading