diff --git a/package.json b/package.json index bd145c5..cb121bd 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/features/api/lib/client.ts b/src/features/api/lib/client.ts index 3df3025..42dac86 100644 --- a/src/features/api/lib/client.ts +++ b/src/features/api/lib/client.ts @@ -1,4 +1,4 @@ -import { setAccessToken, setRefreshToken } from '~/features/auth/storage' +import { setAccessToken } from '~/features/auth/storage' import type { AfterRequestInterceptor, @@ -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 } @@ -39,7 +37,7 @@ const appendAPIKey: BeforeRequestInterceptor = (request) => { return request } -const api = new NetworkProvider({ +export const api = new NetworkProvider({ baseUrl: apiUrl, interceptors: { beforeRequest: [appendAPIKey], @@ -47,4 +45,15 @@ const api = new NetworkProvider({ }, }) -export { api } +export const apiInternal = new NetworkProvider({ + interceptors: { + afterRequest: [persistTokens], + }, +}) + +export const apiSSR = new NetworkProvider({ + baseUrl: apiUrl, + interceptors: { + beforeRequest: [appendAPIKey], + }, +}) diff --git a/src/features/api/lib/privateClient.ts b/src/features/api/lib/privateClient.ts index 46c6a1b..900f4f3 100644 --- a/src/features/api/lib/privateClient.ts +++ b/src/features/api/lib/privateClient.ts @@ -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() @@ -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, diff --git a/src/features/auth/contexts/userContext.tsx b/src/features/auth/contexts/userContext.tsx index 3e3918e..7591712 100644 --- a/src/features/auth/contexts/userContext.tsx +++ b/src/features/auth/contexts/userContext.tsx @@ -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 @@ -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( diff --git a/src/features/auth/hooks/useLogin.ts b/src/features/auth/hooks/useLogin.ts index 74af94b..4b9a34f 100644 --- a/src/features/auth/hooks/useLogin.ts +++ b/src/features/auth/hooks/useLogin.ts @@ -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 } @@ -15,7 +16,9 @@ export const useLogin = () => { const result = useMutation( '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') diff --git a/src/features/auth/storage.ts b/src/features/auth/storage.ts index f8a1856..dd78c83 100644 --- a/src/features/auth/storage.ts +++ b/src/features/auth/storage.ts @@ -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 diff --git a/src/features/core/constants/routes.ts b/src/features/core/constants/routes.ts index 17cffce..f726480 100644 --- a/src/features/core/constants/routes.ts +++ b/src/features/core/constants/routes.ts @@ -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', +} diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts new file mode 100644 index 0000000..0f575c9 --- /dev/null +++ b/src/pages/api/login.ts @@ -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 diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts new file mode 100644 index 0000000..54d06e9 --- /dev/null +++ b/src/pages/api/logout.ts @@ -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 diff --git a/src/pages/api/refreshToken.ts b/src/pages/api/refreshToken.ts new file mode 100644 index 0000000..99ea66b --- /dev/null +++ b/src/pages/api/refreshToken.ts @@ -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 diff --git a/yarn.lock b/yarn.lock index 1a1d5aa..6367a5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -691,6 +691,11 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@types/cookie@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.1.tgz#b29aa1f91a59f35e29ff8f7cb24faf1a3a750554" + integrity sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g== + "@types/hoist-non-react-statics@*": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" @@ -1284,6 +1289,11 @@ convert-source-map@^1.7.0: dependencies: safe-buffer "~5.1.1" +cookie@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + core-js-pure@^3.20.2: version "3.22.4" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.22.4.tgz#a992210f4cad8b32786b8654563776c56b0e0d0a"