Skip to content

Commit 8da9997

Browse files
authored
Merge pull request #511 from nextcloud/enhancement/csrf-expiry-retry-handler
Add retry handler for expired CSRF tokens
2 parents 4dedf6b + 5fd1e77 commit 8da9997

File tree

6 files changed

+164
-4
lines changed

6 files changed

+164
-4
lines changed

lib/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import Axios, { AxiosInstance, CancelTokenStatic } from 'axios'
22
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
33

4-
import { onError } from './interceptors/maintenance-mode'
4+
import { onError as onCsrfTokenError } from './interceptors/csrf-token'
5+
import { onError as onMaintenanceModeError } from './interceptors/maintenance-mode'
56

67
interface CancelableAxiosInstance extends AxiosInstance {
78
CancelToken: CancelTokenStatic
@@ -18,7 +19,8 @@ const cancelableClient: CancelableAxiosInstance = Object.assign(client, {
1819
isCancel: Axios.isCancel,
1920
})
2021

21-
cancelableClient.interceptors.response.use(r => r, onError(cancelableClient))
22+
cancelableClient.interceptors.response.use(r => r, onCsrfTokenError(cancelableClient))
23+
cancelableClient.interceptors.response.use(r => r, onMaintenanceModeError(cancelableClient))
2224

2325
onRequestTokenUpdate(token => client.defaults.headers.requesttoken = token)
2426

lib/interceptors/csrf-token.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { generateUrl } from '@nextcloud/router'
2+
3+
const RETRY_KEY = Symbol('csrf-retry')
4+
5+
export const onError = axios => async (error) => {
6+
const { config, response, request: { responseURL } } = error
7+
const { status } = response
8+
9+
if (status === 412
10+
&& response?.data?.message === 'CSRF check failed'
11+
&& config[RETRY_KEY] === undefined) {
12+
console.warn(`Request to ${responseURL} failed because of a CSRF mismatch. Fetching a new token`)
13+
14+
const { data: { token } } = await axios.get(generateUrl('/csrftoken'))
15+
console.debug(`New request token ${token} fetched`)
16+
axios.defaults.headers.requesttoken = token
17+
18+
return axios({
19+
...config,
20+
headers: {
21+
...config.headers,
22+
requesttoken: token,
23+
},
24+
[RETRY_KEY]: true,
25+
})
26+
}
27+
28+
return Promise.reject(error)
29+
}

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
"build": "rollup --config rollup.config.js",
1717
"check-types": "tsc",
1818
"dev": "rollup --config rollup.config.js --watch",
19-
"test": "jest",
20-
"test:watch": "jest --watchAll"
19+
"test": "jest --setupFiles '<rootDir>/test/setup.js'",
20+
"test:watch": "jest --setupFiles '<rootDir>/test/setup.js' --watchAll"
2121
},
2222
"repository": {
2323
"type": "git",
@@ -37,6 +37,7 @@
3737
"homepage": "https://github.com/nextcloud/nextcloud-axios#readme",
3838
"dependencies": {
3939
"@nextcloud/auth": "^2.0.0",
40+
"@nextcloud/router": "^2.0.0",
4041
"axios": "^0.27.2",
4142
"tslib": "^2.4.0"
4243
},
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { onError } from '../../lib/interceptors/csrf-token'
2+
3+
describe('CSRF token', () => {
4+
5+
let axiosMock
6+
let interceptor
7+
8+
beforeEach(() => {
9+
axiosMock = jest.fn()
10+
axiosMock.get = jest.fn()
11+
axiosMock.defaults = {
12+
headers: {
13+
requesttoken: 'old',
14+
},
15+
},
16+
interceptor = onError(axiosMock)
17+
})
18+
19+
it('does not retry successful requests', async () => {
20+
try {
21+
await interceptor({
22+
config: {},
23+
response: {
24+
status: 200,
25+
headers: {},
26+
},
27+
request: {
28+
responseURL: '/some/url',
29+
},
30+
})
31+
} catch (e) {
32+
expect(e.response.status).toBe(200)
33+
expect(axiosMock).not.toHaveBeenCalled()
34+
return
35+
}
36+
fail('Should not be reached')
37+
})
38+
39+
it('does not retry if header missing', async () => {
40+
try {
41+
await interceptor({
42+
config: {
43+
retryIfMaintenanceMode: true,
44+
},
45+
response: {
46+
status: 412,
47+
headers: {},
48+
},
49+
request: {
50+
responseURL: '/some/url',
51+
},
52+
})
53+
} catch (e) {
54+
expect(e.response.status).toBe(412)
55+
expect(axiosMock).not.toHaveBeenCalled()
56+
return
57+
}
58+
fail('Should not be reached')
59+
})
60+
61+
it('does retry', async () => {
62+
axiosMock.mockReturnValue({
63+
status: 200,
64+
})
65+
axiosMock.get.mockReturnValue(Promise.resolve({
66+
data: {
67+
token: '123',
68+
},
69+
headers: {},
70+
}))
71+
const response = await interceptor({
72+
config: {},
73+
response: {
74+
status: 412,
75+
data: {
76+
message: 'CSRF check failed',
77+
},
78+
},
79+
request: {
80+
responseURL: '/some/url',
81+
},
82+
})
83+
expect(axiosMock).toHaveBeenCalled()
84+
expect(axiosMock.get).toHaveBeenCalled()
85+
expect(axiosMock.defaults.headers.requesttoken).toBe('123')
86+
expect(response?.status).toBe(200)
87+
})
88+
})

test/setup.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
global.OC = {
2+
config: {
3+
modRewriteWorking: true,
4+
},
5+
isUserAdmin() {
6+
return false
7+
},
8+
}

0 commit comments

Comments
 (0)