Skip to content

Commit a9fcd89

Browse files
committed
Merge tag '1.12.3-RC3'
This pre-release was accidentally left out of release 1.12.3 somehow.
2 parents d27c36a + b234847 commit a9fcd89

File tree

6 files changed

+309
-26
lines changed

6 files changed

+309
-26
lines changed

NEWS

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
== Version 2.3.1 (unreleased) ==
1+
== Version 2.4.0 (unreleased) ==
22

33
`webauthn-server-core`:
44

@@ -8,6 +8,20 @@ Fixes:
88
configured, if the `aaguid` in the attestation data is zero, the call to
99
`AttestationTrustSource.findTrustRoots` will fall back to reading the AAGUID
1010
from the attestation certificate if possible.
11+
* Fixed bug in `RelyingParty.finishAssertion` where if
12+
`StartAssertionOptions.userHandle` was set, it did not propagate to
13+
`RelyingParty.finishAssertion` and caused an error saying username and user
14+
handle are both absent unless a user handle was returned by the authenticator.
15+
This was originally released in pre-release `1.12.3-RC3`, but was accidentally
16+
left out of the `1.12.3` release.
17+
18+
New features:
19+
20+
* Added `userHandle` field to `AssertionRequest` as part of above bug fix.
21+
`userHandle` is mutually exclusive with `username`. This was originally
22+
released in pre-release `1.12.3-RC3`, but was accidentally left out of the
23+
`1.12.3` release.
24+
1125

1226
`webauthn-server-attestation`:
1327

webauthn-server-core/src/main/java/com/yubico/webauthn/AssertionRequest.java

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
/**
3939
* A combination of a {@link PublicKeyCredentialRequestOptions} and, optionally, a {@link
40-
* #getUsername() username}.
40+
* #getUsername() username} or {@link #getUserHandle() user handle}.
4141
*/
4242
@Value
4343
@Builder(toBuilder = true)
@@ -52,26 +52,58 @@ public class AssertionRequest {
5252
/**
5353
* The username of the user to authenticate, if the user has already been identified.
5454
*
55-
* <p>If this is absent, this indicates that this is a request for an assertion by a <a
55+
* <p>This is mutually exclusive with {@link #getUserHandle() userHandle}; setting this will unset
56+
* {@link #getUserHandle() userHandle}. When parsing from JSON, {@link #getUserHandle()
57+
* userHandle} takes precedence over this.
58+
*
59+
* <p>If both this and {@link #getUserHandle() userHandle} are empty, this indicates that this is
60+
* a request for an assertion by a <a
5661
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
5762
* credential</a>, and identification of the user has been deferred until the response is
5863
* received.
5964
*/
6065
private final String username;
6166

67+
/**
68+
* The user handle of the user to authenticate, if the user has already been identified.
69+
*
70+
* <p>This is mutually exclusive with {@link #getUsername() username}; setting this will unset
71+
* {@link #getUsername() username}. When parsing from JSON, this takes precedence over {@link
72+
* #getUsername() username}.
73+
*
74+
* <p>If both this and {@link #getUsername() username} are empty, this indicates that this is a
75+
* request for an assertion by a <a
76+
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
77+
* credential</a>, and identification of the user has been deferred until the response is
78+
* received.
79+
*/
80+
private final ByteArray userHandle;
81+
6282
@JsonCreator
6383
private AssertionRequest(
6484
@NonNull @JsonProperty("publicKeyCredentialRequestOptions")
6585
PublicKeyCredentialRequestOptions publicKeyCredentialRequestOptions,
66-
@JsonProperty("username") String username) {
86+
@JsonProperty("username") String username,
87+
@JsonProperty("userHandle") ByteArray userHandle) {
6788
this.publicKeyCredentialRequestOptions = publicKeyCredentialRequestOptions;
68-
this.username = username;
89+
90+
if (userHandle != null) {
91+
this.username = null;
92+
this.userHandle = userHandle;
93+
} else {
94+
this.username = username;
95+
this.userHandle = null;
96+
}
6997
}
7098

7199
/**
72100
* The username of the user to authenticate, if the user has already been identified.
73101
*
74-
* <p>If this is absent, this indicates that this is a request for an assertion by a <a
102+
* <p>This is mutually exclusive with {@link #getUserHandle()}; if this is present, then {@link
103+
* #getUserHandle()} will be empty.
104+
*
105+
* <p>If both this and {@link #getUserHandle()} are empty, this indicates that this is a request
106+
* for an assertion by a <a
75107
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
76108
* credential</a>, and identification of the user has been deferred until the response is
77109
* received.
@@ -80,6 +112,22 @@ public Optional<String> getUsername() {
80112
return Optional.ofNullable(username);
81113
}
82114

115+
/**
116+
* The user handle of the user to authenticate, if the user has already been identified.
117+
*
118+
* <p>This is mutually exclusive with {@link #getUsername()}; if this is present, then {@link
119+
* #getUsername()} will be empty.
120+
*
121+
* <p>If both this and {@link #getUsername()} are empty, this indicates that this is a request for
122+
* an assertion by a <a
123+
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
124+
* credential</a>, and identification of the user has been deferred until the response is
125+
* received.
126+
*/
127+
public Optional<ByteArray> getUserHandle() {
128+
return Optional.ofNullable(userHandle);
129+
}
130+
83131
/**
84132
* Serialize this {@link AssertionRequest} value to JSON suitable for sending to the client.
85133
*
@@ -140,6 +188,7 @@ public static AssertionRequestBuilder.MandatoryStages builder() {
140188

141189
public static class AssertionRequestBuilder {
142190
private String username = null;
191+
private ByteArray userHandle = null;
143192

144193
public static class MandatoryStages {
145194
private final AssertionRequestBuilder builder = new AssertionRequestBuilder();
@@ -161,7 +210,10 @@ public AssertionRequestBuilder publicKeyCredentialRequestOptions(
161210
/**
162211
* The username of the user to authenticate, if the user has already been identified.
163212
*
164-
* <p>If this is absent, this indicates that this is a request for an assertion by a <a
213+
* <p>This is mutually exclusive with {@link #userHandle(ByteArray)}; setting this to non-empty
214+
* will unset {@link #userHandle(ByteArray)}.
215+
*
216+
* <p>If this is empty, this indicates that this is a request for an assertion by a <a
165217
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
166218
* credential</a>, and identification of the user has been deferred until the response is
167219
* received.
@@ -173,13 +225,55 @@ public AssertionRequestBuilder username(@NonNull Optional<String> username) {
173225
/**
174226
* The username of the user to authenticate, if the user has already been identified.
175227
*
176-
* <p>If this is absent, this indicates that this is a request for an assertion by a <a
228+
* <p>This is mutually exclusive with {@link #userHandle(ByteArray)}; setting this to non-<code>
229+
* null</code> will unset {@link #userHandle(ByteArray)}.
230+
*
231+
* <p>If this is empty, this indicates that this is a request for an assertion by a <a
177232
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
178233
* credential</a>, and identification of the user has been deferred until the response is
179234
* received.
180235
*/
181236
public AssertionRequestBuilder username(String username) {
182237
this.username = username;
238+
if (username != null) {
239+
this.userHandle = null;
240+
}
241+
return this;
242+
}
243+
244+
/**
245+
* The user handle of the user to authenticate, if the user has already been identified.
246+
*
247+
* <p>This is mutually exclusive with {@link #username(String)}; setting this to non-empty will
248+
* unset {@link #username(String)}.
249+
*
250+
* <p>If both this and {@link #username(String)} are empty, this indicates that this is a
251+
* request for an assertion by a <a
252+
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
253+
* credential</a>, and identification of the user has been deferred until the response is
254+
* received.
255+
*/
256+
public AssertionRequestBuilder userHandle(@NonNull Optional<ByteArray> userHandle) {
257+
return this.userHandle(userHandle.orElse(null));
258+
}
259+
260+
/**
261+
* The user handle of the user to authenticate, if the user has already been identified.
262+
*
263+
* <p>This is mutually exclusive with {@link #username(String)}; setting this to non-<code>null
264+
* </code> will unset {@link #username(String)}.
265+
*
266+
* <p>If both this and {@link #username(String)} are empty, this indicates that this is a
267+
* request for an assertion by a <a
268+
* href="https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#client-side-discoverable-public-key-credential-source">client-side-resident
269+
* credential</a>, and identification of the user has been deferred until the response is
270+
* received.
271+
*/
272+
public AssertionRequestBuilder userHandle(ByteArray userHandle) {
273+
if (userHandle != null) {
274+
this.username = null;
275+
}
276+
this.userHandle = userHandle;
183277
return this;
184278
}
185279
}

webauthn-server-core/src/main/java/com/yubico/webauthn/FinishAssertionSteps.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,11 @@ public void validate() {
124124
class Step6 implements Step<Step7> {
125125

126126
private final Optional<ByteArray> userHandle =
127-
response
128-
.getResponse()
127+
request
129128
.getUserHandle()
130129
.map(Optional::of)
130+
.orElseGet(() -> response.getResponse().getUserHandle())
131+
.map(Optional::of)
131132
.orElseGet(
132133
() ->
133134
request.getUsername().flatMap(credentialRepository::getUserHandleForUsername));
@@ -136,12 +137,7 @@ class Step6 implements Step<Step7> {
136137
request
137138
.getUsername()
138139
.map(Optional::of)
139-
.orElseGet(
140-
() ->
141-
response
142-
.getResponse()
143-
.getUserHandle()
144-
.flatMap(credentialRepository::getUsernameForUserHandle));
140+
.orElseGet(() -> userHandle.flatMap(credentialRepository::getUsernameForUserHandle));
145141

146142
private final Optional<RegisteredCredential> registration =
147143
userHandle.flatMap(uh -> credentialRepository.lookup(response.getId(), uh));
@@ -154,8 +150,18 @@ public Step7 nextStep() {
154150
@Override
155151
public void validate() {
156152
assertTrue(
157-
request.getUsername().isPresent() || response.getResponse().getUserHandle().isPresent(),
153+
request.getUsername().isPresent()
154+
|| request.getUserHandle().isPresent()
155+
|| response.getResponse().getUserHandle().isPresent(),
158156
"At least one of username and user handle must be given; none was.");
157+
if (request.getUserHandle().isPresent()
158+
&& response.getResponse().getUserHandle().isPresent()) {
159+
assertTrue(
160+
request.getUserHandle().get().equals(response.getResponse().getUserHandle().get()),
161+
"User handle set in request (%s) does not match user handle in response (%s).",
162+
request.getUserHandle().get(),
163+
response.getResponse().getUserHandle().get());
164+
}
159165

160166
assertTrue(
161167
userHandle.isPresent(),

webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,7 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio
555555
return AssertionRequest.builder()
556556
.publicKeyCredentialRequestOptions(pkcro.build())
557557
.username(startAssertionOptions.getUsername())
558+
.userHandle(startAssertionOptions.getUserHandle())
558559
.build();
559560
}
560561

webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyAssertionSpec.scala

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ class RelyingPartyAssertionSpec
185185
rpId: RelyingPartyIdentity = Defaults.rpId,
186186
signature: ByteArray = Defaults.signature,
187187
userHandleForResponse: Option[ByteArray] = Some(Defaults.userHandle),
188+
userHandleForRequest: Option[ByteArray] = None,
188189
userHandleForUser: ByteArray = Defaults.userHandle,
189190
usernameForRequest: Option[String] = Some(Defaults.username),
190191
usernameForUser: String = Defaults.username,
@@ -210,6 +211,7 @@ class RelyingPartyAssertionSpec
210211
.build()
211212
)
212213
.username(usernameForRequest.toJava)
214+
.userHandle(userHandleForRequest.toJava)
213215
.build()
214216

215217
val response = PublicKeyCredential
@@ -648,9 +650,9 @@ class RelyingPartyAssertionSpec
648650
) {
649651
val steps = finishAssertion(
650652
credentialRepository = credentialRepository,
651-
usernameForRequest = Some(owner.username),
652-
userHandleForUser = owner.userHandle,
653653
userHandleForResponse = Some(nonOwner.userHandle),
654+
userHandleForUser = owner.userHandle,
655+
usernameForRequest = Some(owner.username),
654656
)
655657
val step: FinishAssertionSteps#Step6 = steps.begin.next
656658

@@ -678,9 +680,9 @@ class RelyingPartyAssertionSpec
678680
it("Succeeds if credential ID is owned by the given user handle.") {
679681
val steps = finishAssertion(
680682
credentialRepository = credentialRepository,
681-
usernameForRequest = Some(owner.username),
682-
userHandleForUser = owner.userHandle,
683683
userHandleForResponse = Some(owner.userHandle),
684+
userHandleForUser = owner.userHandle,
685+
usernameForRequest = Some(owner.username),
684686
)
685687
val step: FinishAssertionSteps#Step6 = steps.begin.next
686688

@@ -707,13 +709,30 @@ class RelyingPartyAssertionSpec
707709
}
708710

709711
it(
710-
"Fails if credential ID is not owned by the given user handle."
712+
"Fails if credential ID is not owned by the user handle in the response."
711713
) {
712714
val steps = finishAssertion(
713715
credentialRepository = credentialRepository,
716+
userHandleForResponse = Some(nonOwner.userHandle),
717+
userHandleForUser = owner.userHandle,
714718
usernameForRequest = None,
719+
)
720+
val step: FinishAssertionSteps#Step6 = steps.begin.next
721+
722+
step.validations shouldBe a[Failure[_]]
723+
step.validations.failed.get shouldBe an[IllegalArgumentException]
724+
step.tryNext shouldBe a[Failure[_]]
725+
}
726+
727+
it(
728+
"Fails if credential ID is not owned by the user handle in the request."
729+
) {
730+
val steps = finishAssertion(
731+
credentialRepository = credentialRepository,
732+
userHandleForRequest = Some(nonOwner.userHandle),
733+
userHandleForResponse = None,
715734
userHandleForUser = owner.userHandle,
716-
userHandleForResponse = Some(nonOwner.userHandle),
735+
usernameForRequest = None,
717736
)
718737
val step: FinishAssertionSteps#Step6 = steps.begin.next
719738

@@ -725,9 +744,25 @@ class RelyingPartyAssertionSpec
725744
it("Fails if neither username nor user handle is given.") {
726745
val steps = finishAssertion(
727746
credentialRepository = credentialRepository,
747+
userHandleForRequest = None,
748+
userHandleForResponse = None,
749+
userHandleForUser = owner.userHandle,
728750
usernameForRequest = None,
751+
)
752+
val step: FinishAssertionSteps#Step6 = steps.begin.next
753+
754+
step.validations shouldBe a[Failure[_]]
755+
step.validations.failed.get shouldBe an[IllegalArgumentException]
756+
step.tryNext shouldBe a[Failure[_]]
757+
}
758+
759+
it("Fails if user handle in request does not agree with user handle in response.") {
760+
val steps = finishAssertion(
761+
credentialRepository = credentialRepository,
762+
userHandleForRequest = Some(owner.userHandle),
763+
userHandleForResponse = Some(nonOwner.userHandle),
729764
userHandleForUser = owner.userHandle,
730-
userHandleForResponse = None,
765+
usernameForRequest = None,
731766
)
732767
val step: FinishAssertionSteps#Step6 = steps.begin.next
733768

@@ -736,12 +771,26 @@ class RelyingPartyAssertionSpec
736771
step.tryNext shouldBe a[Failure[_]]
737772
}
738773

739-
it("Succeeds if credential ID is owned by the given user handle.") {
774+
it("Succeeds if credential ID is owned by the user handle in the response.") {
740775
val steps = finishAssertion(
741776
credentialRepository = credentialRepository,
777+
userHandleForResponse = Some(owner.userHandle),
778+
userHandleForUser = owner.userHandle,
742779
usernameForRequest = None,
780+
)
781+
val step: FinishAssertionSteps#Step6 = steps.begin.next
782+
783+
step.validations shouldBe a[Success[_]]
784+
step.tryNext shouldBe a[Success[_]]
785+
}
786+
787+
it("Succeeds if credential ID is owned by the user handle in the request.") {
788+
val steps = finishAssertion(
789+
credentialRepository = credentialRepository,
790+
userHandleForRequest = Some(owner.userHandle),
791+
userHandleForResponse = None,
743792
userHandleForUser = owner.userHandle,
744-
userHandleForResponse = Some(owner.userHandle),
793+
usernameForRequest = None,
745794
)
746795
val step: FinishAssertionSteps#Step6 = steps.begin.next
747796

0 commit comments

Comments
 (0)