Skip to content

Commit d7ee227

Browse files
platoshataefi
andauthored
feat: synchronise csrf info with service worker (#3506)
* feat: synchronise csrf info with service worker Fixes: #2791 Depends on: #3438 * test(frontend): make old CSRF tests work * test(endpoint): use Service Worker supportive ChromeDeviceTest * fix(frontend): make CSRF updates for in SW * chore(tests): code formatting * test(endpoint): cleanup leftover sw message handler * refactor(frontend): cleaner CsrfInfoSource lifecycle / review suggestions * fix(frontend): enable CSRF metadata updates in Authentication * refactor(frontend): use CsrfInfoSource for authentication * Update packages/ts/frontend/src/CsrfInfoSource.ts Co-authored-by: Soroosh Taefi <[email protected]> --------- Co-authored-by: Soroosh Taefi <[email protected]>
1 parent 78d6b84 commit d7ee227

File tree

12 files changed

+461
-164
lines changed

12 files changed

+461
-164
lines changed

packages/java/tests/spring/endpoints/frontend/src/test-component.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import { AppEndpoint, PagedEndpoint } from '../generated/endpoints';
66
import Direction from '../generated/org/springframework/data/domain/Sort/Direction';
77

88
class TestComponent extends PolymerElement {
9+
#boundSwMessageListener;
10+
11+
constructor(props) {
12+
super(props);
13+
this.#boundSwMessageListener = this.swMessageListener.bind(this);
14+
}
15+
916
static get template() {
1017
return html`
1118
<button id="button">vaadin hello</button><br />
@@ -31,6 +38,7 @@ class TestComponent extends PolymerElement {
3138
<button id="pageOfEntities" on-click="getPageOfEntities">Get page of entities</button>
3239
<button id="denied" on-click="denied">endpoint denied</button><br />
3340
<button id="logout" on-click="logout">logout</button><br />
41+
<button id="helloAnonymousFromServiceWorker" on-click="helloAnonymousFromServiceWorker">helloAnonymous from serviceWorker</button><br />
3442
<form method="POST" action="login">
3543
<input id="username" name="username" />
3644
<input id="password" name="password" />
@@ -48,6 +56,22 @@ class TestComponent extends PolymerElement {
4856
await fetch('logout');
4957
}
5058

59+
connectedCallback() {
60+
super.connectedCallback();
61+
navigator.serviceWorker?.addEventListener('message', this.#boundSwMessageListener);
62+
}
63+
64+
disconnectedCallback() {
65+
super.disconnectedCallback();
66+
navigator.serviceWorker?.removeEventListener('message', this.#boundSwMessageListener);
67+
}
68+
69+
swMessageListener(event) {
70+
if (event.data && event.data.type === 'sw-app-message') {
71+
this.$.content.textContent = event.data.text;
72+
}
73+
}
74+
5175
hello(e) {
5276
appEndpoint
5377
.hello('Friend')
@@ -158,5 +182,11 @@ class TestComponent extends PolymerElement {
158182
.then((response) => (this.$.content.textContent = response))
159183
.catch((error) => (this.$.content.textContent = 'Error:' + error));
160184
}
185+
186+
helloAnonymousFromServiceWorker(e) {
187+
window.navigator.serviceWorker?.ready.then((registration) => {
188+
registration.active.postMessage('helloAnonymous');
189+
});
190+
}
161191
}
162192
customElements.define(TestComponent.is, TestComponent);

packages/java/tests/spring/endpoints/frontend/sw-app.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import {AppEndpoint} from "Frontend/generated/endpoints";
44

55
declare var self: ServiceWorkerGlobalScope;
66

7-
async function main() {
8-
const hello = await AppEndpoint.helloAnonymous();
9-
const clients = await self.clients.matchAll({ type: "window" });
10-
for (const client of clients) {
11-
client.postMessage({
12-
type: 'sw-app-hello',
13-
hello,
14-
});
7+
self.addEventListener('message', (e: ExtendableMessageEvent) => {
8+
let endpoint: undefined | (() => Promise<string | undefined>) = undefined;
9+
if (e.data === 'helloAnonymous') {
10+
endpoint = AppEndpoint.helloAnonymous;
1511
}
16-
}
17-
// TODO: enable endpoints and call main()
18-
Promise.reject().then(main, () => {});
12+
if (endpoint) {
13+
e.waitUntil(endpoint()?.then((result) => {
14+
e.source?.postMessage({
15+
type: 'sw-app-message',
16+
text: `SW message: ${result}`,
17+
});
18+
}));
19+
}
20+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2000-2017 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.connect;
17+
18+
import com.vaadin.flow.testutil.ChromeDeviceTest;
19+
import com.vaadin.testbench.TestBenchElement;
20+
import org.junit.Before;
21+
import org.junit.Test;
22+
import org.openqa.selenium.WebElement;
23+
import org.openqa.selenium.support.ui.ExpectedConditions;
24+
25+
/**
26+
* Class for testing issues in a spring-boot container.
27+
*/
28+
public class ServiceWorkerIT extends ChromeDeviceTest {
29+
30+
private void openTestUrl(String url) {
31+
getDriver().get(getRootURL() + url);
32+
}
33+
34+
private TestBenchElement testComponent;
35+
private WebElement content;
36+
37+
@Override
38+
@Before
39+
public void setup() throws Exception {
40+
super.setup();
41+
load();
42+
}
43+
44+
@Test
45+
public void should_requestAnonymously_when_calledInServiceWorker() {
46+
WebElement button = testComponent.$(TestBenchElement.class)
47+
.id("helloAnonymousFromServiceWorker");
48+
button.click();
49+
50+
// Wait for the server connect response
51+
verifyContent("SW message: Hello, stranger!");
52+
}
53+
54+
private void load() {
55+
openTestUrl("/");
56+
waitForServiceWorkerReady();
57+
testComponent = $("test-component").waitForFirst();
58+
content = testComponent.$(TestBenchElement.class).id("content");
59+
}
60+
61+
private void verifyContent(String expected) {
62+
waitUntil(
63+
ExpectedConditions.textToBePresentInElement(content, expected),
64+
25);
65+
}
66+
}

packages/ts/frontend/src/Authentication.ts

Lines changed: 51 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,55 @@
11
import type { MiddlewareClass, MiddlewareContext, MiddlewareNext } from './Connect.js';
22
import CookieManager from './CookieManager.js';
3-
import {
4-
getSpringCsrfInfo,
5-
getSpringCsrfTokenHeadersForAuthRequest,
6-
getSpringCsrfTokenParametersForAuthRequest,
3+
import csrfInfoSource, {
74
VAADIN_CSRF_HEADER,
8-
} from './CsrfUtils.js';
5+
clearCsrfInfoMeta,
6+
type CsrfInfo,
7+
CsrfInfoType,
8+
extractCsrfInfoFromMeta,
9+
updateCsrfInfoMeta,
10+
} from './CsrfInfoSource.js';
11+
12+
function createHeaders(headerEntries: ReadonlyArray<readonly [name: string, value: string]>): Headers {
13+
const headers = new Headers();
14+
for (const [name, value] of headerEntries) {
15+
headers.append(name, value);
16+
}
17+
return headers;
18+
}
919

1020
const JWT_COOKIE_NAME = 'jwt.headerAndPayload';
1121

12-
function getSpringCsrfTokenFromResponseBody(body: string): Record<string, string> {
22+
async function getCsrfInfoFromResponseBody(body: string): Promise<CsrfInfo> {
1323
const doc = new DOMParser().parseFromString(body, 'text/html');
14-
return getSpringCsrfInfo(doc);
24+
return extractCsrfInfoFromMeta(doc);
1525
}
1626

17-
function clearSpringCsrfMetaTags() {
18-
Array.from(
19-
document.head.querySelectorAll('meta[name="_csrf"], meta[name="_csrf_header"], meta[name="_csrf_parameter"]'),
20-
).forEach((el) => el.remove());
21-
}
22-
23-
function updateSpringCsrfMetaTags(springCsrfInfo: Record<string, string>) {
24-
clearSpringCsrfMetaTags();
25-
const headerNameMeta: HTMLMetaElement = document.createElement('meta');
26-
headerNameMeta.name = '_csrf_header';
27-
headerNameMeta.content = springCsrfInfo._csrf_header;
28-
document.head.appendChild(headerNameMeta);
29-
const tokenMeta: HTMLMetaElement = document.createElement('meta');
30-
tokenMeta.name = '_csrf';
31-
tokenMeta.content = springCsrfInfo._csrf;
32-
document.head.appendChild(tokenMeta);
33-
}
34-
35-
const getVaadinCsrfTokenFromResponseBody = (body: string): string | undefined => {
36-
const match = /window\.Vaadin = \{TypeScript: \{"csrfToken":"([0-9a-zA-Z\\-]{36})"\}\};/iu.exec(body);
37-
return match ? match[1] : undefined;
38-
};
39-
40-
async function updateCsrfTokensBasedOnResponse(response: Response): Promise<string | undefined> {
27+
async function updateCsrfTokensBasedOnResponse(response: Response): Promise<void> {
4128
const responseText = await response.text();
42-
const token = getVaadinCsrfTokenFromResponseBody(responseText);
43-
const springCsrfTokenInfo = getSpringCsrfTokenFromResponseBody(responseText);
44-
updateSpringCsrfMetaTags(springCsrfTokenInfo);
45-
46-
return token;
29+
const csrfInfo = await getCsrfInfoFromResponseBody(responseText);
30+
updateCsrfInfoMeta(csrfInfo, document);
4731
}
4832

49-
async function doFetchLogout(logoutUrl: URL | string, headers: Record<string, string>) {
33+
async function doFetchLogout(
34+
logoutUrl: URL | string,
35+
headerEntries: ReadonlyArray<readonly [name: string, value: string]>,
36+
) {
37+
const headers = createHeaders(headerEntries);
5038
const response = await fetch(logoutUrl, { headers, method: 'POST' });
5139
if (!response.ok) {
5240
throw new Error(`failed to logout with response ${response.status}`);
5341
}
5442

5543
await updateCsrfTokensBasedOnResponse(response);
44+
csrfInfoSource.reset();
5645

5746
return response;
5847
}
5948

60-
async function doFormLogout(url: URL | string, parameters: Record<string, string>): Promise<void> {
49+
async function doFormLogout(
50+
url: URL | string,
51+
formDataEntries: ReadonlyArray<readonly [name: string, value: string]>,
52+
): Promise<void> {
6153
const logoutUrl = typeof url === 'string' ? url : url.toString();
6254

6355
// Create form to send POST request
@@ -67,7 +59,7 @@ async function doFormLogout(url: URL | string, parameters: Record<string, string
6759
form.style.display = 'none';
6860

6961
// Add data to form as hidden input fields
70-
for (const [name, value] of Object.entries(parameters)) {
62+
for (const [name, value] of formDataEntries) {
7163
const input = document.createElement('input');
7264
input.setAttribute('type', 'hidden');
7365
input.setAttribute('name', name);
@@ -96,17 +88,18 @@ async function doLogout(doc: Document, options?: LogoutOptions): Promise<Respons
9688
const shouldSubmitFormLogout = !options?.navigate && !options?.onSuccess;
9789
// this assumes the default Spring Security logout configuration (handler URL)
9890
const logoutUrl = options?.logoutUrl ?? 'logout';
91+
const csrfInfo = doc === document ? await csrfInfoSource.get() : await extractCsrfInfoFromMeta(doc);
9992
if (shouldSubmitFormLogout) {
100-
const parameters = getSpringCsrfTokenParametersForAuthRequest(doc);
101-
await doFormLogout(logoutUrl, parameters);
93+
const formDataEntries = csrfInfo.type === CsrfInfoType.SPRING ? csrfInfo.formDataEntries : [];
94+
await doFormLogout(logoutUrl, formDataEntries);
10295
// This should never be reached, as form submission will navigate away
10396
return new Response(null, {
10497
status: 500,
10598
statusText: 'Form submission did not navigate away.',
10699
} as ResponseInit);
107100
}
108-
const headers = getSpringCsrfTokenHeadersForAuthRequest(doc);
109-
return await doFetchLogout(logoutUrl, headers);
101+
const headerEntries = csrfInfo.type === CsrfInfoType.SPRING ? csrfInfo.headerEntries : [];
102+
return await doFetchLogout(logoutUrl, headerEntries);
110103
}
111104

112105
export interface LoginResult {
@@ -200,8 +193,9 @@ export async function login(username: string, password: string, options?: LoginO
200193
data.append('password', password);
201194

202195
const loginProcessingUrl = options?.loginProcessingUrl ?? 'login';
203-
const headers = getSpringCsrfTokenHeadersForAuthRequest(document);
204-
headers.source = 'typescript';
196+
const csrfInfo = await csrfInfoSource.get();
197+
const headers = createHeaders(csrfInfo.headerEntries);
198+
headers.append('source', 'typescript');
205199
const response = await fetch(loginProcessingUrl, {
206200
body: data,
207201
headers,
@@ -217,16 +211,19 @@ export async function login(username: string, password: string, options?: LoginO
217211
const loginSuccessful = response.ok && result === 'success';
218212

219213
if (loginSuccessful) {
220-
const vaadinCsrfToken = response.headers.get('Vaadin-CSRF') ?? undefined;
221-
222214
const springCsrfHeader = response.headers.get('Spring-CSRF-header') ?? undefined;
223215
const springCsrfToken = response.headers.get('Spring-CSRF-token') ?? undefined;
224216
if (springCsrfHeader && springCsrfToken) {
225-
const springCsrfTokenInfo: Record<string, string> = {};
226-
springCsrfTokenInfo._csrf = springCsrfToken;
227-
// eslint-disable-next-line camelcase
228-
springCsrfTokenInfo._csrf_header = springCsrfHeader;
229-
updateSpringCsrfMetaTags(springCsrfTokenInfo);
217+
updateCsrfInfoMeta(
218+
{
219+
headerEntries: [[springCsrfHeader, springCsrfToken]],
220+
formDataEntries: [],
221+
type: CsrfInfoType.SPRING,
222+
timestamp: Date.now(),
223+
},
224+
document,
225+
);
226+
csrfInfoSource.reset();
230227
}
231228

232229
if (options?.onSuccess) {
@@ -242,7 +239,6 @@ export async function login(username: string, password: string, options?: LoginO
242239
defaultUrl,
243240
error: false,
244241
redirectUrl: savedUrl,
245-
token: vaadinCsrfToken,
246242
};
247243
}
248244
return {
@@ -279,7 +275,8 @@ export async function logout(options?: LogoutOptions): Promise<void> {
279275
response = await doLogout(doc, options);
280276
} catch (error) {
281277
// clear the token if the call fails
282-
clearSpringCsrfMetaTags();
278+
clearCsrfInfoMeta(document);
279+
csrfInfoSource.reset();
283280
throw error;
284281
}
285282
} finally {

packages/ts/frontend/src/Connect.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactiveControllerHost } from '@lit/reactive-element';
22
import type * as CommonFrontendModule from '@vaadin/common-frontend';
3-
import { getCsrfTokenHeadersForEndpointRequest } from './CsrfUtils.js';
3+
import csrfInfoSource from './CsrfInfoSource.js';
44
import {
55
EndpointError,
66
EndpointResponseError,
@@ -356,11 +356,10 @@ export class ConnectClient {
356356
throw new TypeError(`2 arguments required, but got only ${arguments.length}`);
357357
}
358358

359-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
360-
const csrfHeaders = globalThis.document ? getCsrfTokenHeadersForEndpointRequest(globalThis.document) : {};
359+
const csrfInfo = await csrfInfoSource.get();
361360
const headers: Record<string, string> = {
362361
Accept: 'application/json',
363-
...csrfHeaders,
362+
...Object.fromEntries(csrfInfo.headerEntries),
364363
};
365364

366365
const [paramsWithoutFiles, files] = extractFiles(params ?? {});

packages/ts/frontend/src/CookieManager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ export function calculatePath({ pathname }: URL): string {
55
}
66

77
const CookieManager: typeof Cookies = Cookies.withAttributes({
8-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
9-
path: calculatePath(new URL(globalThis.document?.baseURI ?? globalThis.location?.href ?? 'data:')),
8+
path: calculatePath(
9+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
10+
globalThis.document ? new URL(globalThis.document.baseURI) : new URL('.', globalThis.location.href),
11+
),
1012
});
1113

1214
export default CookieManager;

0 commit comments

Comments
 (0)