Skip to content

Commit 3c2a081

Browse files
committed
Capture remember-device cookie on WebAuthn and passkey auth
validateCheckWebAuthn and validateCheckPasskey used a body-only request helper and dropped the Set-Cookie header, so the server-issued pi_remember_device cookie was discarded and "remember this device" silently did nothing when authenticating with a security key or passkey. Both now propagate the Set-Cookie via PIResponse.setCookieHeaders like validateCheck. Adds a regression test covering both paths.
1 parent f0bb6de commit 3c2a081

3 files changed

Lines changed: 106 additions & 4 deletions

File tree

Changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Changelog
22

33
### 1.6.0 - 6 August 2026
4+
* Capture the remember-device cookie on WebAuthn and passkey authentications (previously only the OTP path
5+
propagated the Set-Cookie, so "remember this device" silently did nothing with a security key / passkey).
46
* Improved logging hygiene: the Authorization token, the `X-API-Key`, and token seeds/OTP values are no longer
57
written to the log, and logged values are sanitized to prevent forged log lines.
68
* Requests are now executed synchronously per call; the internal fixed-size thread pool was removed, so the

src/main/java/org/privacyidea/PrivacyIDEA.java

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,15 @@ public PIResponse validateCheckWebAuthn(String user, String transactionID, Strin
304304
hdrs.put(HEADER_ORIGIN, origin);
305305
hdrs.putAll(headers);
306306

307-
String response = runRequest(ENDPOINT_VALIDATE_CHECK, params, hdrs, false, POST);
308-
return this.parser.parsePIResponse(response);
307+
PIRequestResult result = submitRequest(ENDPOINT_VALIDATE_CHECK, params, hdrs, false, POST);
308+
PIResponse piResponse = this.parser.parsePIResponse(result.body);
309+
if (piResponse != null)
310+
{
311+
// Capture the remember-device Set-Cookie the server issues on a successful auth (same as the
312+
// plain validateCheck path) — otherwise "remember this device" silently does nothing via WebAuthn.
313+
piResponse.setCookieHeaders = result.setCookies;
314+
}
315+
return piResponse;
309316
}
310317

311318
/**
@@ -366,8 +373,15 @@ public PIResponse validateCheckPasskey(String transactionID, String passkeyRespo
366373
hdrs.put(HEADER_ORIGIN, origin);
367374
hdrs.putAll(headers);
368375

369-
String response = runRequest(ENDPOINT_VALIDATE_CHECK, params, hdrs, false, POST);
370-
return this.parser.parsePIResponse(response);
376+
PIRequestResult result = submitRequest(ENDPOINT_VALIDATE_CHECK, params, hdrs, false, POST);
377+
PIResponse piResponse = this.parser.parsePIResponse(result.body);
378+
if (piResponse != null)
379+
{
380+
// Capture the remember-device Set-Cookie the server issues on a successful auth (same as the
381+
// plain validateCheck path) — otherwise "remember this device" silently does nothing via passkey.
382+
piResponse.setCookieHeaders = result.setCookies;
383+
}
384+
return piResponse;
371385
}
372386

373387
/**
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2026 NetKnights GmbH - nils.behlen@netknights.it
3+
* <p>
4+
* SPDX-License-Identifier: Apache-2.0
5+
* <p>
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
* <p>
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
* <p>
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package org.privacyidea;
19+
20+
import java.util.Collections;
21+
import java.util.concurrent.TimeUnit;
22+
23+
import org.junit.After;
24+
import org.junit.Before;
25+
import org.junit.Test;
26+
import org.mockserver.integration.ClientAndServer;
27+
import org.mockserver.model.HttpRequest;
28+
import org.mockserver.model.HttpResponse;
29+
import org.mockserver.model.MediaType;
30+
31+
import static org.junit.Assert.assertNotNull;
32+
import static org.junit.Assert.assertTrue;
33+
34+
/**
35+
* Regression: the remember-device {@code Set-Cookie} the server issues on a successful auth must be surfaced
36+
* on {@link PIResponse#setCookieHeaders} for ALL auth paths, not only the plain OTP {@code validateCheck}.
37+
* WebAuthn and passkey previously used a body-only request helper and dropped the cookie, so "remember this
38+
* device" silently did nothing when the user authenticated with a security key / passkey.
39+
*/
40+
public class TestRememberDeviceCookieCapture
41+
{
42+
private ClientAndServer mockServer;
43+
private PrivacyIDEA privacyIDEA;
44+
private static final String COOKIE = "pi_remember_device=1:abcdef; Path=/; Max-Age=604800";
45+
46+
@Before
47+
public void setup()
48+
{
49+
mockServer = ClientAndServer.startClientAndServer(1080);
50+
mockServer.when(HttpRequest.request().withMethod("POST").withPath("/validate/check"))
51+
.respond(HttpResponse.response()
52+
.withContentType(MediaType.APPLICATION_JSON)
53+
.withHeader("Set-Cookie", COOKIE)
54+
.withBody(Utils.matchingOneToken())
55+
.withDelay(TimeUnit.MILLISECONDS, 20));
56+
57+
privacyIDEA = PrivacyIDEA.newBuilder("https://127.0.0.1:1080", "test")
58+
.verifySSL(false)
59+
.logger(new PILogImplementation())
60+
.build();
61+
}
62+
63+
@Test
64+
public void webauthnSurfacesSetCookie()
65+
{
66+
PIResponse r = privacyIDEA.validateCheckWebAuthn("testuser", "txn-1", "{}", "https://origin");
67+
assertNotNull(r);
68+
assertNotNull("setCookieHeaders must be populated on the WebAuthn path", r.setCookieHeaders);
69+
assertTrue(r.setCookieHeaders.stream().anyMatch(c -> c.contains("pi_remember_device")));
70+
}
71+
72+
@Test
73+
public void passkeySurfacesSetCookie()
74+
{
75+
PIResponse r = privacyIDEA.validateCheckPasskey("txn-2", "{}", "https://origin", Collections.emptyMap());
76+
assertNotNull(r);
77+
assertNotNull("setCookieHeaders must be populated on the passkey path", r.setCookieHeaders);
78+
assertTrue(r.setCookieHeaders.stream().anyMatch(c -> c.contains("pi_remember_device")));
79+
}
80+
81+
@After
82+
public void tearDown()
83+
{
84+
mockServer.stop();
85+
}
86+
}

0 commit comments

Comments
 (0)