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

Advanced: Authentication step 2 – API Routes #19

Draft
wants to merge 3 commits into
base: lessons/13-auth
Choose a base branch
from
Draft
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: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@hookform/resolvers": "^2.9.1",
"cookie": "^0.5.0",
"date-fns": "^2.28.0",
"next": "12.1.6",
"react": "18.1.0",
Expand All @@ -27,6 +28,7 @@
"@strv/eslint-config-react": "^4.1.0",
"@strv/eslint-config-typescript": "^3.1.2",
"@strv/prettier-config": "^2.0.0",
"@types/cookie": "^0.5.1",
"@types/node": "17.0.31",
"@types/react": "18.0.8",
"@types/react-dom": "18.0.3",
Expand Down
19 changes: 14 additions & 5 deletions src/features/api/lib/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { setAccessToken, setRefreshToken } from '~/features/auth/storage'
import { setAccessToken } from '~/features/auth/storage'

import type {
AfterRequestInterceptor,
Expand All @@ -25,9 +25,7 @@ const persistTokens: AfterRequestInterceptor = (
response
) => {
const accessToken = response.headers.get('Authorization')
const refreshToken = response.headers.get('Refresh-Token')
if (accessToken) setAccessToken(accessToken)
if (refreshToken) setRefreshToken(refreshToken)
return response
}

Expand All @@ -39,12 +37,23 @@ const appendAPIKey: BeforeRequestInterceptor = (request) => {
return request
}

const api = new NetworkProvider({
export const api = new NetworkProvider({
baseUrl: apiUrl,
interceptors: {
beforeRequest: [appendAPIKey],
afterRequest: [persistTokens],
},
})

export { api }
export const apiInternal = new NetworkProvider({
interceptors: {
afterRequest: [persistTokens],
},
})

export const apiSSR = new NetworkProvider({
baseUrl: apiUrl,
interceptors: {
beforeRequest: [appendAPIKey],
},
})
15 changes: 4 additions & 11 deletions src/features/api/lib/privateClient.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import router from 'next/router'

import { api } from '~/features/api/lib/client'
import { api, apiInternal } from '~/features/api/lib/client'
import type {
AfterRequestInterceptor,
BeforeRequestInterceptor,
} from '~/features/api/lib/network-provider'
import { getAccessToken, getRefreshToken } from '~/features/auth/storage'
import { Routes } from '~/features/core/constants/routes'
import { getAccessToken } from '~/features/auth/storage'
import { ApiRoutes, Routes } from '~/features/core/constants/routes'

const appendAccessToken: BeforeRequestInterceptor = (request) => {
const accessToken = getAccessToken()
Expand All @@ -21,15 +21,8 @@ const handleUnauthorized: AfterRequestInterceptor = async (
context
) => {
if (response.status === 403 || response.status === 401) {
const refreshToken = getRefreshToken()
if (!refreshToken) {
return response
}

// persistTokens interceptor will store the tokens if refresh succeeds
const refreshResponse = await api.post('/auth/native', {
json: { refreshToken },
})
const refreshResponse = await apiInternal.post(ApiRoutes.REFRESH_TOKEN)
if (refreshResponse.status >= 400) {
void router.replace({
pathname: Routes.LOGIN,
Expand Down
5 changes: 3 additions & 2 deletions src/features/auth/contexts/userContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { useEffect } from 'react'
import { useMemo, useCallback } from 'react'
import React, { createContext, useState, useContext } from 'react'

import { apiInternal } from '~/features/api/lib/client'
import {
getPersistedUser,
removeAccessToken,
removePersistedUser,
removeRefreshToken,
} from '~/features/auth/storage'
import { ApiRoutes } from '~/features/core/constants/routes'

export type UserType = {
id: string
Expand Down Expand Up @@ -39,8 +40,8 @@ export const UserContextProvider: FC<{ children: ReactNode }> = ({
const handleLogout = useCallback(() => {
setUser(null)
removePersistedUser()
removeRefreshToken()
removeAccessToken()
void apiInternal.post(ApiRoutes.LOGOUT)
}, [])

const value = useMemo(
Expand Down
9 changes: 6 additions & 3 deletions src/features/auth/hooks/useLogin.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useMutation } from 'react-query'

import { api } from '~/features/api'
import { apiInternal } from '~/features/api/lib/client'
import type { UserType } from '~/features/auth/contexts/userContext'
import { useUserContext } from '~/features/auth/contexts/userContext'
import { setPersistedUser } from '~/features/auth/storage'
import { ApiRoutes } from '~/features/core/constants/routes'

type LoginInput = {
export type LoginInput = {
email: string
password: string
}
Expand All @@ -15,7 +16,9 @@ export const useLogin = () => {
const result = useMutation<UserType, Error, LoginInput>(
'login',
async (credentials) => {
const response = await api.post('/auth/native', { json: credentials })
const response = await apiInternal.post(ApiRoutes.LOGIN, {
json: credentials,
})

if (!response.ok) {
throw new Error('Login Failed')
Expand Down
12 changes: 0 additions & 12 deletions src/features/auth/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,6 @@ export const removeAccessToken = () => {
window.localStorage.removeItem('accessToken')
}

export const getRefreshToken = () => {
return window.localStorage.getItem('refreshToken')
}

export const setRefreshToken = (refreshToken: string) => {
window.localStorage.setItem('refreshToken', refreshToken)
}

export const removeRefreshToken = () => {
window.localStorage.removeItem('refreshToken')
}

export const getPersistedUser = (): UserType | null => {
const userJson = window.localStorage.getItem('user')
return userJson ? (JSON.parse(userJson) as UserType) : null
Expand Down
6 changes: 6 additions & 0 deletions src/features/core/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ export enum Routes {
LOGIN = '/login',
CREATE_EVENT = '/events/create',
}

export enum ApiRoutes {
LOGIN = '/api/login',
LOGOUT = '/api/logout',
REFRESH_TOKEN = '/api/refreshToken',
}
59 changes: 59 additions & 0 deletions src/pages/api/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import cookie from 'cookie'
import type { NextApiRequest, NextApiResponse } from 'next'

import { apiSSR } from '~/features/api/lib/client'
import type { UserType } from '~/features/auth/contexts/userContext'
import type { LoginInput } from '~/features/auth/hooks/useLogin'

const REFRESH_COOKIE_AGE = 60 * 60 * 24 * 30
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
const API_KEY = process.env.NEXT_PUBLIC_API_KEY

export const REFRESH_TOKEN_COOKIE = 'refresh_token'
export const REFRESH_COOKIE_OPTIONS = {
// javascript cant access
httpOnly: true,
// allowed only through https - but localhost does not use it
secure: process.env.NODE_ENV !== 'development',
// same as true
sameSite: 'strict',
// ideally as refresh token or custom
maxAge: REFRESH_COOKIE_AGE,
// root path of cookie
path: '/',
} as const

async function handler(req: NextApiRequest, nextApiResponse: NextApiResponse) {
if (req.method !== 'POST') return
if (!BASE_URL || !API_KEY) return

const credentials = req.body as LoginInput
if (!credentials.email || !credentials.password) return

const loginResponse = await apiSSR.post('/auth/native', {
json: credentials,
})

if (loginResponse.status >= 400) {
return nextApiResponse
.status(loginResponse.status)
.json({ message: 'Login Failed' })
}

const user = (await loginResponse.json()) as UserType

const accessToken = loginResponse.headers.get('Authorization')
accessToken && nextApiResponse.setHeader('Authorization', accessToken)

const refreshToken = loginResponse.headers.get('Refresh-Token')
refreshToken &&
nextApiResponse.setHeader(
'Set-Cookie',
cookie.serialize(REFRESH_TOKEN_COOKIE, refreshToken, {
...REFRESH_COOKIE_OPTIONS,
})
)

nextApiResponse.status(200).json(user)
}
export default handler
18 changes: 18 additions & 0 deletions src/pages/api/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import cookie from 'cookie'
import type { NextApiRequest, NextApiResponse } from 'next'

import { REFRESH_TOKEN_COOKIE, REFRESH_COOKIE_OPTIONS } from '~/pages/api/login'

function handler(_req: NextApiRequest, res: NextApiResponse) {
res.setHeader(
'Set-Cookie',
cookie.serialize(REFRESH_TOKEN_COOKIE, '', {
...REFRESH_COOKIE_OPTIONS,
maxAge: 0,
})
)

res.status(200).end('Session expired')
}

export default handler
31 changes: 31 additions & 0 deletions src/pages/api/refreshToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { NextApiRequest, NextApiResponse } from 'next'

import { apiSSR } from '~/features/api/lib/client'
import type { UserType } from '~/features/auth/contexts/userContext'
import { REFRESH_TOKEN_COOKIE } from '~/pages/api/login'

async function handler(req: NextApiRequest, nextApiResponse: NextApiResponse) {
const refreshToken = req.cookies[REFRESH_TOKEN_COOKIE]
if (!refreshToken) {
return nextApiResponse
.status(400)
.json({ message: 'Missing refresh token' })
}
const refreshResponse = await apiSSR.post('/auth/native', {
json: { refreshToken },
})

if (refreshResponse.status === 401 || refreshResponse.status === 403) {
return nextApiResponse
.status(refreshResponse.status)
.json({ message: 'Invalid refresh token' })
}

const refreshResponseData = (await refreshResponse.json()) as UserType
const accessToken = refreshResponse.headers.get('Authorization')
accessToken && nextApiResponse.setHeader('Authorization', accessToken)

return nextApiResponse.status(200).json(refreshResponseData)
}

export default handler
10 changes: 10 additions & 0 deletions yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.