Skip to content

Commit b234847

Browse files
committed
Pass StartAssertionOptions.userHandle through to finishAssertion
1 parent c81c9a8 commit b234847

File tree

6 files changed

+303
-25
lines changed

6 files changed

+303
-25
lines changed

NEWS

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ Fixes:
99
* Fixed bug in `RelyingParty.finishAssertion` that would throw a nondescript
1010
`NoSuchElementException` if username and user handle are both absent, instead
1111
of an `IllegalArgumentException` with a better error message.
12+
* Fixed bug in `RelyingParty.finishAssertion` where if
13+
`StartAssertionOptions.userHandle` was set, it did not propagate to
14+
`RelyingParty.finishAssertion` and caused an error saying username and user
15+
handle are both absent unless a user handle was returned by the authenticator.
16+
17+
New features:
18+
19+
* Added `userHandle` field to `AssertionRequest` as part of above bug fix.
20+
`userHandle` is mutually exclusive with `username`.
1221

1322

1423
== Version 1.12.2 ==

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
@@ -119,10 +119,11 @@ default AssertionResult run() throws InvalidSignatureCountException {
119119
class Step0 implements Step<Step1> {
120120

121121
private final Optional<ByteArray> userHandle =
122-
response
123-
.getResponse()
122+
request
124123
.getUserHandle()
125124
.map(Optional::of)
125+
.orElseGet(() -> response.getResponse().getUserHandle())
126+
.map(Optional::of)
126127
.orElseGet(
127128
() ->
128129
request.getUsername().flatMap(credentialRepository::getUserHandleForUsername));
@@ -131,12 +132,7 @@ class Step0 implements Step<Step1> {
131132
request
132133
.getUsername()
133134
.map(Optional::of)
134-
.orElseGet(
135-
() ->
136-
response
137-
.getResponse()
138-
.getUserHandle()
139-
.flatMap(credentialRepository::getUsernameForUserHandle));
135+
.orElseGet(() -> userHandle.flatMap(credentialRepository::getUsernameForUserHandle));
140136

141137
@Override
142138
public Step1 nextStep() {
@@ -146,8 +142,18 @@ public Step1 nextStep() {
146142
@Override
147143
public void validate() {
148144
assure(
149-
request.getUsername().isPresent() || response.getResponse().getUserHandle().isPresent(),
145+
request.getUsername().isPresent()
146+
|| request.getUserHandle().isPresent()
147+
|| response.getResponse().getUserHandle().isPresent(),
150148
"At least one of username and user handle must be given; none was.");
149+
if (request.getUserHandle().isPresent()
150+
&& response.getResponse().getUserHandle().isPresent()) {
151+
assure(
152+
request.getUserHandle().get().equals(response.getResponse().getUserHandle().get()),
153+
"User handle set in request (%s) does not match user handle in response (%s).",
154+
request.getUserHandle().get(),
155+
response.getResponse().getUserHandle().get());
156+
}
151157
assure(
152158
userHandle.isPresent(),
153159
"User handle not found for username: %s",

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
@@ -483,6 +483,7 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio
483483
return AssertionRequest.builder()
484484
.publicKeyCredentialRequestOptions(pkcro.build())
485485
.username(startAssertionOptions.getUsername())
486+
.userHandle(startAssertionOptions.getUserHandle())
486487
.build();
487488
}
488489

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
@@ -180,6 +180,7 @@ class RelyingPartyAssertionSpec
180180
rpId: RelyingPartyIdentity = Defaults.rpId,
181181
signature: ByteArray = Defaults.signature,
182182
userHandleForResponse: Option[ByteArray] = Some(Defaults.userHandle),
183+
userHandleForRequest: Option[ByteArray] = None,
183184
userHandleForUser: ByteArray = Defaults.userHandle,
184185
usernameForRequest: Option[String] = Some(Defaults.username),
185186
usernameForUser: String = Defaults.username,
@@ -205,6 +206,7 @@ class RelyingPartyAssertionSpec
205206
.build()
206207
)
207208
.username(usernameForRequest.asJava)
209+
.userHandle(userHandleForRequest.asJava)
208210
.build()
209211

210212
val response = PublicKeyCredential
@@ -521,9 +523,9 @@ class RelyingPartyAssertionSpec
521523
) {
522524
val steps = finishAssertion(
523525
credentialRepository = credentialRepository,
524-
usernameForRequest = Some(owner.username),
525-
userHandleForUser = owner.userHandle,
526526
userHandleForResponse = Some(nonOwner.userHandle),
527+
userHandleForUser = owner.userHandle,
528+
usernameForRequest = Some(owner.username),
527529
)
528530
val step: FinishAssertionSteps#Step2 = steps.begin.next.next
529531

@@ -535,9 +537,9 @@ class RelyingPartyAssertionSpec
535537
it("Succeeds if credential ID is owned by the given user handle.") {
536538
val steps = finishAssertion(
537539
credentialRepository = credentialRepository,
538-
usernameForRequest = Some(owner.username),
539-
userHandleForUser = owner.userHandle,
540540
userHandleForResponse = Some(owner.userHandle),
541+
userHandleForUser = owner.userHandle,
542+
usernameForRequest = Some(owner.username),
541543
)
542544
val step: FinishAssertionSteps#Step2 = steps.begin.next.next
543545

@@ -548,13 +550,30 @@ class RelyingPartyAssertionSpec
548550

549551
describe("If the user was not identified before the authentication ceremony was initiated, verify that credential.response.userHandle is present, and that the user identified by this value is the owner of credentialSource.") {
550552
it(
551-
"Fails if credential ID is not owned by the given user handle."
553+
"Fails if credential ID is not owned by the user handle in the response."
552554
) {
553555
val steps = finishAssertion(
554556
credentialRepository = credentialRepository,
557+
userHandleForResponse = Some(nonOwner.userHandle),
558+
userHandleForUser = owner.userHandle,
555559
usernameForRequest = None,
560+
)
561+
val step: FinishAssertionSteps#Step2 = steps.begin.next.next
562+
563+
step.validations shouldBe a[Failure[_]]
564+
step.validations.failed.get shouldBe an[IllegalArgumentException]
565+
step.tryNext shouldBe a[Failure[_]]
566+
}
567+
568+
it(
569+
"Fails if credential ID is not owned by the user handle in the request."
570+
) {
571+
val steps = finishAssertion(
572+
credentialRepository = credentialRepository,
573+
userHandleForRequest = Some(nonOwner.userHandle),
574+
userHandleForResponse = None,
556575
userHandleForUser = owner.userHandle,
557-
userHandleForResponse = Some(nonOwner.userHandle),
576+
usernameForRequest = None,
558577
)
559578
val step: FinishAssertionSteps#Step2 = steps.begin.next.next
560579

@@ -566,9 +585,25 @@ class RelyingPartyAssertionSpec
566585
it("Fails if neither username nor user handle is given.") {
567586
val steps = finishAssertion(
568587
credentialRepository = credentialRepository,
588+
userHandleForRequest = None,
589+
userHandleForResponse = None,
590+
userHandleForUser = owner.userHandle,
569591
usernameForRequest = None,
592+
)
593+
val step: FinishAssertionSteps#Step0 = steps.begin
594+
595+
step.validations shouldBe a[Failure[_]]
596+
step.validations.failed.get shouldBe an[IllegalArgumentException]
597+
step.tryNext shouldBe a[Failure[_]]
598+
}
599+
600+
it("Fails if user handle in request does not agree with user handle in response.") {
601+
val steps = finishAssertion(
602+
credentialRepository = credentialRepository,
603+
userHandleForRequest = Some(owner.userHandle),
604+
userHandleForResponse = Some(nonOwner.userHandle),
570605
userHandleForUser = owner.userHandle,
571-
userHandleForResponse = None,
606+
usernameForRequest = None,
572607
)
573608
val step: FinishAssertionSteps#Step0 = steps.begin
574609

@@ -577,12 +612,26 @@ class RelyingPartyAssertionSpec
577612
step.tryNext shouldBe a[Failure[_]]
578613
}
579614

580-
it("Succeeds if credential ID is owned by the given user handle.") {
615+
it("Succeeds if credential ID is owned by the user handle in the response.") {
581616
val steps = finishAssertion(
582617
credentialRepository = credentialRepository,
618+
userHandleForResponse = Some(owner.userHandle),
619+
userHandleForUser = owner.userHandle,
583620
usernameForRequest = None,
621+
)
622+
val step: FinishAssertionSteps#Step2 = steps.begin.next.next
623+
624+
step.validations shouldBe a[Success[_]]
625+
step.tryNext shouldBe a[Success[_]]
626+
}
627+
628+
it("Succeeds if credential ID is owned by the user handle in the request.") {
629+
val steps = finishAssertion(
630+
credentialRepository = credentialRepository,
631+
userHandleForRequest = Some(owner.userHandle),
632+
userHandleForResponse = None,
584633
userHandleForUser = owner.userHandle,
585-
userHandleForResponse = Some(owner.userHandle),
634+
usernameForRequest = None,
586635
)
587636
val step: FinishAssertionSteps#Step2 = steps.begin.next.next
588637

0 commit comments

Comments
 (0)