Skip to content

Commit 21b1c82

Browse files
committed
fixup! feat: add setRequestToken and fetchRequestToken methods
1 parent 72b4fcb commit 21b1c82

File tree

7 files changed

+127
-78
lines changed

7 files changed

+127
-78
lines changed
File renamed without changes.

lib/eventbus.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { NextcloudUser } from './user.ts'
77

88
declare module '@nextcloud/event-bus' {
99
export interface NextcloudEvents {
10-
'csrf-token-update': { token: string }
10+
'csrf-token-update': { token: string, _internal?: true }
1111
// mapping of 'event name' => 'event type'
1212
'user:info:changed': NextcloudUser
1313
}

lib/requestToken.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,23 @@ export interface CsrfTokenObserver {
1010
(token: string): void
1111
}
1212

13+
_subscribeToTokenUpdates() // TODO: remove once we drop support for Nextcloud 33.0.1 and before
14+
1315
/**
1416
* Get current request token
1517
*
1618
* @return Current request token or null if not set
1719
*/
1820
export function getRequestToken(): string | null {
21+
if (globalThis._nc_auth_requestToken) {
22+
return globalThis._nc_auth_requestToken
23+
}
24+
1925
if (globalThis.document) {
26+
// for service workers or other contexts without DOM we need to safeguard this
2027
return document.head.dataset.requesttoken ?? null
2128
}
22-
// for service workers or other contexts without DOM, we keep the token in memory
23-
return globalThis._nc_auth_requestToken ?? null
29+
return null
2430
}
2531

2632
/**
@@ -35,12 +41,18 @@ export function setRequestToken(token: string): void {
3541
throw new Error('Invalid CSRF token given', { cause: { token } })
3642
}
3743

44+
if (globalThis._nc_auth_requestToken === token) {
45+
// token is the same as before, no need to update and especially no need to notify the observers
46+
return
47+
}
48+
49+
globalThis._nc_auth_requestToken = token
3850
if (globalThis.document) {
51+
// For DOM environments we also set the token to the DOM, so it is available for legacy code
3952
document.head.dataset.requesttoken = token
40-
} else {
41-
globalThis._nc_auth_requestToken = token
4253
}
43-
emit('csrf-token-update', { token })
54+
55+
emit('csrf-token-update', { token, _internal: true })
4456
}
4557

4658
/**
@@ -62,40 +74,34 @@ export async function fetchRequestToken(): Promise<string> {
6274
return token
6375
}
6476

65-
const _observers: CsrfTokenObserver[] = []
6677
/**
6778
* Add an observer which is called when the CSRF token changes
6879
*
6980
* @param observer The observer
7081
*/
7182
export function onRequestTokenUpdate(observer: CsrfTokenObserver): void {
72-
_subscribeToTokenUpdates()
73-
_observers.push(observer)
83+
subscribe('csrf-token-update', async ({ token }) => {
84+
try {
85+
observer(token)
86+
} catch (error) {
87+
// we cannot use the logger as the logger uses this library = circular dependency
88+
// eslint-disable-next-line no-console
89+
console.error('Error updating CSRF token observer', error)
90+
}
91+
})
7492
}
7593

76-
let _initialized = false
7794
/**
7895
* Subscribe to token update events from server.
7996
*
80-
* This is legacy and not needed once all supported server versions use `setRequestToken` of this library.
97+
* @todo - This is legacy and not needed once all supported server versions use `setRequestToken` of this library.
8198
*/
8299
function _subscribeToTokenUpdates(): void {
83-
if (_initialized) {
84-
return
85-
}
86-
87-
_initialized = true
88100
// Listen to server event and keep token in sync
89-
subscribe('csrf-token-update', (event) => {
90-
setRequestToken(event.token)
91-
for (const observer of _observers) {
92-
try {
93-
observer(event.token)
94-
} catch (error) {
95-
// we cannot use the logger as the logger uses this library = circular dependency
96-
// eslint-disable-next-line no-console
97-
console.error('Error updating CSRF token observer', error)
98-
}
101+
subscribe('csrf-token-update', ({ token, _internal }) => {
102+
if (!_internal) {
103+
// Only update the token if the event is not emitted from this library, otherwise we would end in a loop
104+
setRequestToken(token)
99105
}
100106
})
101107
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
import { emit } from '@nextcloud/event-bus'
7+
import { beforeEach, describe, expect, it, test, vi } from 'vitest'
8+
9+
test('it does set the new token form legacy event', async () => {
10+
const { getRequestToken } = await import('../lib/requestToken.ts')
11+
mockToken('old-token')
12+
13+
// older server versions emitted the event directly without using this libary
14+
emit('csrf-token-update', { token: 'new-token' })
15+
expect(getRequestToken()).toBe('new-token')
16+
})
17+
18+
describe('request token observers', () => {
19+
beforeEach(() => {
20+
vi.resetModules()
21+
vi.resetAllMocks()
22+
mockToken(undefined)
23+
})
24+
25+
it('request token observer is called', async () => {
26+
const { onRequestTokenUpdate, setRequestToken } = await import('../lib/requestToken.ts')
27+
const observer = vi.fn(() => { })
28+
29+
onRequestTokenUpdate(observer)
30+
expect(observer).not.toBeCalled()
31+
32+
setRequestToken('token123')
33+
expect(observer).toBeCalledTimes(1)
34+
expect(observer).toBeCalledWith('token123')
35+
})
36+
37+
it('handle exception in observer', async () => {
38+
const { onRequestTokenUpdate, setRequestToken } = await import('../lib/requestToken.ts')
39+
const spy = vi.spyOn(window.console, 'error')
40+
const observer = vi.fn(() => {
41+
throw new Error('!Error!')
42+
})
43+
// silence the console
44+
spy.mockImplementationOnce(() => {})
45+
46+
onRequestTokenUpdate(observer)
47+
setRequestToken('token123')
48+
49+
expect(observer).toBeCalledTimes(1)
50+
expect(spy).toHaveBeenCalledOnce()
51+
})
52+
})
53+
54+
/**
55+
* Mock the request token directly so we can it reading it.
56+
*
57+
* @param token - The CSRF token to mock
58+
*/
59+
function mockToken(token?: string) {
60+
if (token === undefined) {
61+
delete document.head.dataset.requesttoken
62+
delete globalThis._nc_auth_requestToken
63+
} else {
64+
document.head.dataset.requesttoken = token
65+
globalThis._nc_auth_requestToken = token
66+
}
67+
}

test/requestToken.test.ts

Lines changed: 23 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { http, HttpResponse } from 'msw'
77
import { setupServer } from 'msw/node'
88
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
9-
import { fetchRequestToken, getRequestToken, onRequestTokenUpdate, setRequestToken } from '../lib/requestToken.ts'
9+
import { fetchRequestToken, getRequestToken, setRequestToken } from '../lib/requestToken.ts'
1010

1111
const eventbus = vi.hoisted(() => ({ emit: vi.fn(), subscribe: vi.fn() }))
1212
vi.mock('@nextcloud/event-bus', () => eventbus)
@@ -15,14 +15,24 @@ const server = setupServer()
1515

1616
describe('getRequestToken', () => {
1717
it('can read the token from DOM', () => {
18-
mockToken('tokenmock-123')
18+
mockToken('tokenmock-123', undefined)
1919
expect(getRequestToken()).toBe('tokenmock-123')
2020
})
2121

2222
it('can handle missing token', () => {
2323
mockToken(undefined)
2424
expect(getRequestToken()).toBeNull()
2525
})
26+
27+
it('can handle cache token', () => {
28+
mockToken('cached-token')
29+
expect(getRequestToken()).toBe('cached-token')
30+
})
31+
32+
it('prioritizes cached token', () => {
33+
mockToken('dom-token', 'cached-token')
34+
expect(getRequestToken()).toBe('cached-token')
35+
})
2636
})
2737

2838
describe('setRequestToken', () => {
@@ -33,7 +43,7 @@ describe('setRequestToken', () => {
3343
it('does emit an event on change', () => {
3444
setRequestToken('new-token')
3545
expect(eventbus.emit).toBeCalledTimes(1)
36-
expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
46+
expect(eventbus.emit).toBeCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' }))
3747
})
3848

3949
it('does set the new token to the DOM', () => {
@@ -62,50 +72,6 @@ describe('setRequestToken', () => {
6272
})
6373
})
6474

65-
describe('request token observers', () => {
66-
beforeEach(() => {
67-
mockToken(undefined)
68-
})
69-
70-
it('can update token by event', async () => {
71-
expect(getRequestToken()).toBeNull()
72-
73-
onRequestTokenUpdate(() => {})
74-
expect(eventbus.subscribe).toBeCalledWith('csrf-token-update', expect.any(Function))
75-
eventbus.subscribe.mock.calls[0][1]({ token: 'token123' })
76-
77-
expect(getRequestToken()).toBe('token123')
78-
})
79-
80-
it('request token observer is called', async () => {
81-
const observer = vi.fn(() => { })
82-
83-
onRequestTokenUpdate(observer)
84-
expect(observer).not.toBeCalled()
85-
86-
expect(eventbus.subscribe).toBeCalledWith('csrf-token-update', expect.any(Function))
87-
eventbus.subscribe.mock.calls[0][1]({ token: 'token123' })
88-
89-
expect(observer).toBeCalledTimes(1)
90-
})
91-
92-
it('handle exception in observer', async () => {
93-
const spy = vi.spyOn(window.console, 'error')
94-
const observer = vi.fn(() => {
95-
throw new Error('!Error!')
96-
})
97-
// silence the console
98-
spy.mockImplementationOnce(() => {})
99-
100-
onRequestTokenUpdate(observer)
101-
expect(eventbus.subscribe).toBeCalledWith('csrf-token-update', expect.any(Function))
102-
eventbus.subscribe.mock.calls[0][1]({ token: 'token123' })
103-
104-
expect(observer).toBeCalledTimes(1)
105-
expect(spy).toHaveBeenCalledOnce()
106-
})
107-
})
108-
10975
describe('fetchRequestToken', () => {
11076
const successfullCsrf = http.get('/index.php/csrftoken', () => {
11177
return HttpResponse.json({ token: 'new-token' })
@@ -127,20 +93,19 @@ describe('fetchRequestToken', () => {
12793

12894
beforeEach(() => {
12995
vi.resetAllMocks()
96+
mockToken('oldToken')
13097
})
13198

13299
it('correctly parses response', async () => {
133100
server.use(successfullCsrf)
134101

135-
mockToken('oldToken')
136102
const token = await fetchRequestToken()
137103
expect(token).toBe('new-token')
138104
})
139105

140106
it('sets the token', async () => {
141107
server.use(successfullCsrf)
142108

143-
mockToken('oldToken')
144109
await fetchRequestToken()
145110
expect(getRequestToken()).toBe('new-token')
146111
})
@@ -150,7 +115,7 @@ describe('fetchRequestToken', () => {
150115

151116
await fetchRequestToken()
152117
expect(eventbus.emit).toHaveBeenCalledOnce()
153-
expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
118+
expect(eventbus.emit).toBeCalledWith('csrf-token-update', expect.objectContaining({ token: 'new-token' }))
154119
})
155120

156121
it('handles 403 error due to invalid cookies', async () => {
@@ -182,11 +147,18 @@ describe('fetchRequestToken', () => {
182147
* Mock the request token directly so we can it reading it.
183148
*
184149
* @param token - The CSRF token to mock
150+
* @param internalToken - The internal (cached version of the DOM token) token to mock, if null the `token` will be used as internal token
185151
*/
186-
function mockToken(token?: string) {
152+
function mockToken(token?: string, internalToken: string | undefined | null = null) {
187153
if (token === undefined) {
188154
delete document.head.dataset.requesttoken
189155
} else {
190156
document.head.dataset.requesttoken = token
191157
}
158+
159+
if (internalToken === null) {
160+
globalThis._nc_auth_requestToken = token
161+
} else {
162+
globalThis._nc_auth_requestToken = internalToken
163+
}
192164
}

test/tsconfig.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"include": [".", "../lib", "../*.d.ts"],
4+
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"include": ["./lib"],
2+
"include": ["./lib", "./*.d.ts"],
33
"compilerOptions": {
44
"allowSyntheticDefaultImports": true,
55
"allowImportingTsExtensions": true,

0 commit comments

Comments
 (0)