Skip to content
Open
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
9 changes: 9 additions & 0 deletions cypress/fixtures/refreshTokenResponse/error_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"errors": [
{
"source": "Doorkeeper::OAuth::Error",
"detail": "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.",
"code": "invalid_grant"
}
]
}
13 changes: 13 additions & 0 deletions cypress/fixtures/refreshTokenResponse/valid_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"data": {
"id": 1,
"type": "token",
"attributes": {
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 99,
"refresh_token": "test-refresh-token",
"created_at": 999
}
}
}
11 changes: 11 additions & 0 deletions cypress/fixtures/surveyListResponse/unauthorized_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"errors": [
{
"message": "The access token expired",
"extensions": {
"code": "invalid_token",
"state": "unauthorized"
}
}
]
}
34 changes: 34 additions & 0 deletions cypress/integration/RefreshToken/refreshToken.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { formSelectors } from './selectors'

describe('Refresh Token', () => {
context('given expired access token', () => {
context('given valid refresh token', () => {
it('redirects back to home page', () => {
cy.mockSignInResponse(200, 'signInResponse/valid_response.json')
cy.mockSurveyListResponse(401, 'surveyListResponse/unauthorized_response.json')
cy.mockRefreshTokenResponse(200, 'refreshTokenResponse/valid_response.json')

cy.signIn(Cypress.env('CYPRESS_VALID_EMAIL'), Cypress.env('CYPRESS_VALID_PASSWORD'))
cy.findByTestId(formSelectors.signInButton).click()

cy.url().should('not.include', '/sign-in')
cy.url().should('include', '/')

cy.mockSurveyListResponse(200, 'surveyListResponse/valid_response.json')
})
})

context('given invalid refresh token', () => {
it('redirects back to sign in page', () => {
cy.mockSignInResponse(200, 'signInResponse/valid_response.json')
cy.mockSurveyListResponse(401, 'surveyListResponse/unauthorized_response.json')
cy.mockRefreshTokenResponse(400, 'refreshTokenResponse/error_response.json')

cy.signIn(Cypress.env('CYPRESS_VALID_EMAIL'), Cypress.env('CYPRESS_VALID_PASSWORD'))
cy.findByTestId(formSelectors.signInButton).click()

cy.url().should('include', '/sign-in')
})
})
})
})
5 changes: 5 additions & 0 deletions cypress/integration/RefreshToken/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const formSelectors = {
signInButton: 'formButton',
}

export { formSelectors }
1 change: 1 addition & 0 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
import '@testing-library/cypress/add-commands'
import './commands/signIn'
import './commands/forgotPassword'
import './commands/refreshToken'
import './commands/surveyList'
10 changes: 10 additions & 0 deletions cypress/support/commands/refreshToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const mockRefreshTokenResponse = (statusCode: number, fixture: string): void => {
cy.intercept('POST', '**/api/v1/oauth/token', (req) => {
req.reply({
statusCode: statusCode,
fixture: fixture
})
})
}

Cypress.Commands.add('mockRefreshTokenResponse', mockRefreshTokenResponse)
2 changes: 2 additions & 0 deletions cypress/support/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ declare namespace Cypress {
forgotPassword(email: string): Cypress.Chainable<void>

mockSurveyListResponse(statusCode: number, fixture: string): Cypress.Chainable<void>

mockRefreshTokenResponse(statusCode: number, fixture: string): Cypress.Chainable<void>
}
}
16 changes: 16 additions & 0 deletions src/adapters/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AxiosResponse } from 'axios'

import { postWithJsonBodyHeaders } from 'adapters/headers'
import requestManager from 'lib/requestManager'
import LocalStorage from 'services/localStorage'

class AuthAdapter {
static DEFAULT_PAYLOAD = {
Expand Down Expand Up @@ -42,6 +43,21 @@ class AuthAdapter {

return requestManager('POST', `${process.env.REACT_APP_NIMBLE_SURVEY_BASE_URL}/api/v1/passwords`, requestOptions)
}

static refreshAccessToken = (): Promise<AxiosResponse> => {
const requestOptions = {
headers: {
...postWithJsonBodyHeaders
},
data: {
grant_type: 'refresh_token',
refresh_token: LocalStorage.get('refresh_token'),
...AuthAdapter.DEFAULT_PAYLOAD
}
}

return requestManager('POST', `${process.env.REACT_APP_NIMBLE_SURVEY_BASE_URL}/api/v1/oauth/token`, requestOptions)
}
}

export default AuthAdapter
17 changes: 14 additions & 3 deletions src/adapters/survey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import {
gql,
useQuery,
DocumentNode,
ApolloError
ApolloError,
from
} from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'

import LocalStorage from 'services/localStorage'
import refreshAccessToken from 'services/refreshToken'

class SurveyAdapter {
static getClient = (): ApolloClient<NormalizedCacheObject> => {
Expand All @@ -17,7 +22,7 @@ class SurveyAdapter {
})

const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('access_token')
const token = LocalStorage.get('access_token')
return {
headers: {
...headers,
Expand All @@ -26,8 +31,14 @@ class SurveyAdapter {
}
})

const errorLink = onError(({ networkError }) => {
if (networkError?.message.includes('401')) {
refreshAccessToken()
}
})

const client = new ApolloClient({
link: authLink.concat(httpLink),
link: from([authLink, errorLink, httpLink]),
cache: new InMemoryCache()
})

Expand Down
3 changes: 2 additions & 1 deletion src/contexts/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { createContext, Dispatch, useReducer, useEffect } from 'react'

import * as Constants from 'constants/auth'
import AuthReducer from 'reducers/auth'
import LocalStorage from 'services/localStorage'

type AuthProvider = {
children: JSX.Element
Expand All @@ -28,7 +29,7 @@ const AuthProvider = ({ children }: AuthProvider): JSX.Element => {
const [state, dispatch] = useReducer(AuthReducer, initialState)

useEffect(() => {
const refreshToken = localStorage.getItem('refresh_token')
const refreshToken = LocalStorage.get('refresh_token')
if (!state.isAuthenticated && refreshToken) {
dispatch({ type: Constants.REFRESH })
}
Expand Down
11 changes: 4 additions & 7 deletions src/reducers/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Constants from 'constants/auth'
import { AuthState } from 'contexts/auth'
import LocalStorage from 'services/localStorage'

/* eslint-disable camelcase */
type AuthTypePayload = {
Expand All @@ -22,17 +23,13 @@ const AuthReducer = (state: AuthState, action: ActionType): AuthState => {
if (!data) {
return state
}
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
localStorage.setItem('token_type', data.token_type)

LocalStorage.setToken(data.access_token, data.refresh_token, data.token_type)

return { ...state, isAuthenticated: true }
}
case Constants.LOGOUT: {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('token_type')
localStorage.removeItem('lastVisitedRoute')
LocalStorage.clear()

return { ...state, isAuthenticated: false }
}
Expand Down
5 changes: 3 additions & 2 deletions src/routes/routeAuthentication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useContext } from 'react'
import { Route, Redirect } from 'react-router-dom'

import { AuthContext } from 'contexts/auth'
import LocalStorage from 'services/localStorage'

type RouteAuthentication = {
component: () => JSX.Element
Expand All @@ -11,7 +12,7 @@ type RouteAuthentication = {

const PrivateRoute = ({ ...props }: RouteAuthentication): JSX.Element => {
const { state } = useContext(AuthContext)
localStorage.setItem('lastVisitedRoute', window.location.pathname)
LocalStorage.set('lastVisitedRoute', window.location.pathname)

if (state.isAuthenticated) {
return <Route {...props} />
Expand All @@ -21,7 +22,7 @@ const PrivateRoute = ({ ...props }: RouteAuthentication): JSX.Element => {

const PublicRoute = ({ ...props }: RouteAuthentication): JSX.Element => {
const { state } = useContext(AuthContext)
const lastVisitedRoute = localStorage.getItem('lastVisitedRoute') || '/'
const lastVisitedRoute = LocalStorage.get('lastVisitedRoute') || '/'

if (!state.isAuthenticated) {
return <Route {...props} />
Expand Down
3 changes: 1 addition & 2 deletions src/screens/Home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ const Home = (): JSX.Element => {

if (loading) return <LazyLoader />
if (error?.networkError?.message.includes('401')) {
// TODO: Redirect to home page and clear local storage (this still not working because of REFRESH action bug)
return <p>Unauthorized</p>
return <LazyLoader />
}
if (error) {
// TODO: Create something went wrong screen
Expand Down
3 changes: 1 addition & 2 deletions src/screens/SurveyDetail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ const SurveyDetail = (): JSX.Element => {
const { data, loading, error } = FetchSurveyDetail(surveyID)
if (loading) return <LazyLoader />
if (error?.networkError?.message.includes('401')) {
// TODO: Redirect to home page and clear local storage (this still not working because of REFRESH action bug)
return <p>Unauthorized</p>
return <LazyLoader />
}
if (error) {
// TODO: Create something went wrong screen
Expand Down
Empty file removed src/services/.keep
Empty file.
24 changes: 24 additions & 0 deletions src/services/localStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class LocalStorage {
static get = (key: string): string | null => {
return localStorage.getItem(key)
}

static set = (key: string, value: string): void => {
localStorage.setItem(key, value)
}

static setToken = (accessToken: string, refreshToken: string, tokenType: string): void => {
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
localStorage.setItem('token_type', tokenType)
}

static clear = (): void => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('token_type')
localStorage.removeItem('lastVisitedRoute')
}
}

export default LocalStorage
22 changes: 22 additions & 0 deletions src/services/refreshToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AxiosResponse } from 'axios'

import AuthAdapter from 'adapters/auth'
import LocalStorage from 'services/localStorage'

const refreshAccessToken = async (): Promise<void> => {
await AuthAdapter.refreshAccessToken()
.then((response: AxiosResponse) => {
if (response.status === 200) {
/* eslint-disable camelcase */
const { access_token, refresh_token, token_type } = response.data.data.attributes
LocalStorage.setToken(access_token, refresh_token, token_type)
window.location.href = LocalStorage.get('lastVisitedRoute') || '/'
}
})
.catch(() => {
LocalStorage.clear()
window.location.href = '/sign-in'
})
}

export default refreshAccessToken
17 changes: 17 additions & 0 deletions src/tests/fixtures/oauthTokenResponse.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"data": {
"data": {
"attributes": {
"id": 1,
"type": "token",
"attributes": {
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 99,
"refresh_token": "test-refresh-token",
"created_at": 999
}
}
}
}
}
6 changes: 3 additions & 3 deletions src/tests/screens/Home/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ describe('given Home page is mounted', () => {
})

describe('given unautorized response', () => {
it('renders unauthorized content', async () => {
it('renders lazy loader', async () => {
const mocks = { ...homeErrorResponse(graphQLErrorType.unauthorized) }

const { getByText } = render(
const { getByTestId } = render(
<BrowserRouter>
<MockedProvider mocks={[mocks]} addTypename={false}>
<Home />
Expand All @@ -40,7 +40,7 @@ describe('given Home page is mounted', () => {

await waitFor(() => new Promise((res) => setTimeout(res, 0)))

const result = getByText('Unauthorized')
const result = getByTestId('lazyLoader')
expect(result).toBeInTheDocument()
})
})
Expand Down
6 changes: 3 additions & 3 deletions src/tests/screens/SurveyDetail/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ describe('given SurveyDetail page is mounted', () => {
})

describe('given unauthorized response', () => {
it('renders unauthorized content', async () => {
it('renders lazy loader', async () => {
const surveyID = '1'
const mocks = { ...surveyDetailErrorResponse(graphQLErrorType.unauthorized, surveyID) }

const { getByText } = render(
const { getByTestId } = render(
<MemoryRouter initialEntries={[`survey/${surveyID}`]}>
<Route path="survey/:surveyID">
<MockedProvider mocks={[mocks]} addTypename={false}>
Expand All @@ -48,7 +48,7 @@ describe('given SurveyDetail page is mounted', () => {

await waitFor(() => new Promise((res) => setTimeout(res, 0)))

const result = getByText('Unauthorized')
const result = getByTestId('lazyLoader')
expect(result).toBeInTheDocument()
})
})
Expand Down
Loading