From c5b35e69428d07a5abc549db486fea682dc9c124 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 29 Sep 2021 20:34:29 +0200 Subject: [PATCH] Support user handle as alternative to username in StartAssertionOptions --- NEWS | 4 + README | 2 +- .../com/yubico/webauthn/RelyingParty.java | 9 +- .../webauthn/StartAssertionOptions.java | 167 ++++++++++++++-- .../com/yubico/webauthn/Generators.scala | 23 +++ .../RelyingPartyStartOperationSpec.scala | 189 ++++++++++++++---- .../yubico/internal/util/OptionalUtil.java | 13 ++ 7 files changed, 344 insertions(+), 63 deletions(-) diff --git a/NEWS b/NEWS index 0c3e239ec..c4f50b381 100644 --- a/NEWS +++ b/NEWS @@ -43,6 +43,10 @@ New features: `.fromJson(String)` suitable for encoding to and decoding from JSON. * Added methods `AssertionRequest.toJson()` and `.fromJson(String)` suitable for encoding to and decoding from JSON. +* Added methods `StartAssertionOptions.builder().userHandle(ByteArray)` and + `.userHandle(Optional)` as alternatives to `.username(String)` and + `.username(Optional)`. The `userHandle` methods fill the same function + as, and are mutually exclusive with, the `username` methods. Fixes: diff --git a/README b/README index 30b2e4ef0..a4347c8ca 100644 --- a/README +++ b/README @@ -302,7 +302,7 @@ First, generate authentication parameters and send them to the client: [source,java] ---------- AssertionRequest request = rp.startAssertion(StartAssertionOptions.builder() - .username("alice") + .username("alice") // Or .userHandle(ByteArray) if preferred .build()); String credentialGetJson = request.toCredentialsGetJson(); return credentialGetJson; // Send to client diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java index 854a122c9..e2d08dcea 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/RelyingParty.java @@ -25,6 +25,7 @@ package com.yubico.webauthn; import com.yubico.internal.util.CollectionUtil; +import com.yubico.internal.util.OptionalUtil; import com.yubico.webauthn.attestation.MetadataService; import com.yubico.webauthn.data.AssertionExtensionInputs; import com.yubico.webauthn.data.AttestationConveyancePreference; @@ -449,8 +450,12 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio .challenge(generateChallenge()) .rpId(identity.getId()) .allowCredentials( - startAssertionOptions - .getUsername() + OptionalUtil.orElseOptional( + startAssertionOptions.getUsername(), + () -> + startAssertionOptions + .getUserHandle() + .flatMap(credentialRepository::getUsernameForUserHandle)) .map( un -> new ArrayList<>(credentialRepository.getCredentialIdsForUsername(un)))) diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java index 9dc9b54c6..4c95d9f0a 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartAssertionOptions.java @@ -25,6 +25,7 @@ package com.yubico.webauthn; import com.yubico.webauthn.data.AssertionExtensionInputs; +import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions; import com.yubico.webauthn.data.UserVerificationRequirement; import java.util.Optional; @@ -37,20 +38,10 @@ @Builder(toBuilder = true) public class StartAssertionOptions { - /** - * The username of the user to authenticate, if the user has already been identified. - * - *

If this is absent, that implies a first-factor authentication operation - meaning - * identification of the user is deferred until after receiving the response from the client. - * - *

The default is empty (absent). - * - * @see Client-side-resident - * credential - */ private final String username; + private final ByteArray userHandle; + /** * Extension inputs for this authentication operation. * @@ -91,8 +82,16 @@ public class StartAssertionOptions { /** * The username of the user to authenticate, if the user has already been identified. * - *

If this is absent, that implies a first-factor authentication operation - meaning - * identification of the user is deferred until after receiving the response from the client. + *

Mutually exclusive with {@link #getUserHandle()}. + * + *

If this or {@link #getUserHandle()} is present, then {@link + * RelyingParty#startAssertion(StartAssertionOptions)} will set {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials()} to the list of that user's + * credentials. + * + *

If this and {@link #getUserHandle()} are both absent, that implies a first-factor + * authentication operation - meaning identification of the user is deferred until after receiving + * the response from the client. * *

The default is empty (absent). * @@ -104,6 +103,32 @@ public Optional getUsername() { return Optional.ofNullable(username); } + /** + * The user handle of the user to authenticate, if the user has already been identified. + * + *

Mutually exclusive with {@link #getUsername()}. + * + *

If this or {@link #getUsername()} is present, then {@link + * RelyingParty#startAssertion(StartAssertionOptions)} will set {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials()} to the list of that user's + * credentials. + * + *

If this and {@link #getUsername()} are both absent, that implies a first-factor + * authentication operation - meaning identification of the user is deferred until after receiving + * the response from the client. + * + *

The default is empty (absent). + * + * @see #getUsername() + * @see User Handle + * @see Client-side-resident + * credential + */ + public Optional getUserHandle() { + return Optional.ofNullable(userHandle); + } + /** * The value for {@link PublicKeyCredentialRequestOptions#getUserVerification()} for this * authentication operation. @@ -135,40 +160,130 @@ public Optional getTimeout() { public static class StartAssertionOptionsBuilder { private String username = null; + private ByteArray userHandle = null; private UserVerificationRequirement userVerification = null; private Long timeout = null; /** * The username of the user to authenticate, if the user has already been identified. * - *

If this is absent, that implies a first-factor authentication operation - meaning - * identification of the user is deferred until after receiving the response from the client. + *

Mutually exclusive with {@link #userHandle(Optional)}. Setting this to a present value + * will set {@link #userHandle(Optional)} to empty. + * + *

If this or {@link #userHandle(Optional)} is present, then {@link + * RelyingParty#startAssertion(StartAssertionOptions)} will set {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials()} to the list of that user's + * credentials. + * + *

If this and {@link #getUserHandle()} are both absent, that implies a first-factor + * authentication operation - meaning identification of the user is deferred until after + * receiving the response from the client. * *

The default is empty (absent). * + * @see #username(String) + * @see #userHandle(Optional) + * @see #userHandle(ByteArray) * @see Client-side-resident * credential */ public StartAssertionOptionsBuilder username(@NonNull Optional username) { this.username = username.orElse(null); + if (username.isPresent()) { + this.userHandle = null; + } return this; } /** * The username of the user to authenticate, if the user has already been identified. * - *

If this is absent, that implies a first-factor authentication operation - meaning - * identification of the user is deferred until after receiving the response from the client. + *

Mutually exclusive with {@link #userHandle(Optional)}. Setting this to a non-null value + * will set {@link #userHandle(Optional)} to empty. + * + *

If this or {@link #userHandle(Optional)} is present, then {@link + * RelyingParty#startAssertion(StartAssertionOptions)} will set {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials()} to the list of that user's + * credentials. + * + *

If this and {@link #getUserHandle()} are both absent, that implies a first-factor + * authentication operation - meaning identification of the user is deferred until after + * receiving the response from the client. * *

The default is empty (absent). * + * @see #username(Optional) + * @see #userHandle(Optional) + * @see #userHandle(ByteArray) * @see Client-side-resident * credential */ - public StartAssertionOptionsBuilder username(@NonNull String username) { - return this.username(Optional.of(username)); + public StartAssertionOptionsBuilder username(String username) { + return this.username(Optional.ofNullable(username)); + } + + /** + * The user handle of the user to authenticate, if the user has already been identified. + * + *

Mutually exclusive with {@link #username(Optional)}. Setting this to a present value will + * set {@link #username(Optional)} to empty. + * + *

If this or {@link #username(Optional)} is present, then {@link + * RelyingParty#startAssertion(StartAssertionOptions)} will set {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials()} to the list of that user's + * credentials. + * + *

If this and {@link #getUsername()} are both absent, that implies a first-factor + * authentication operation - meaning identification of the user is deferred until after + * receiving the response from the client. + * + *

The default is empty (absent). + * + * @see #username(String) + * @see #username(Optional) + * @see #userHandle(ByteArray) + * @see User + * Handle + * @see Client-side-resident + * credential + */ + public StartAssertionOptionsBuilder userHandle(@NonNull Optional userHandle) { + this.userHandle = userHandle.orElse(null); + if (userHandle.isPresent()) { + this.username = null; + } + return this; + } + + /** + * The user handle of the user to authenticate, if the user has already been identified. + * + *

Mutually exclusive with {@link #username(Optional)}. Setting this to a non-null value will + * set {@link #username(Optional)} to empty. + * + *

If this or {@link #username(Optional)} is present, then {@link + * RelyingParty#startAssertion(StartAssertionOptions)} will set {@link + * PublicKeyCredentialRequestOptions#getAllowCredentials()} to the list of that user's + * credentials. + * + *

If this and {@link #getUsername()} are both absent, that implies a first-factor + * authentication operation - meaning identification of the user is deferred until after + * receiving the response from the client. + * + *

The default is empty (absent). + * + * @see #username(String) + * @see #username(Optional) + * @see #userHandle(Optional) + * @see Client-side-resident + * credential + */ + public StartAssertionOptionsBuilder userHandle(ByteArray userHandle) { + return this.userHandle(Optional.ofNullable(userHandle)); } /** @@ -200,8 +315,8 @@ public StartAssertionOptionsBuilder userVerification( *

The default is {@link UserVerificationRequirement#PREFERRED}. */ public StartAssertionOptionsBuilder userVerification( - @NonNull UserVerificationRequirement userVerification) { - return this.userVerification(Optional.of(userVerification)); + UserVerificationRequirement userVerification) { + return this.userVerification(Optional.ofNullable(userVerification)); } /** @@ -235,5 +350,13 @@ public StartAssertionOptionsBuilder timeout(@NonNull Optional timeout) { public StartAssertionOptionsBuilder timeout(long timeout) { return this.timeout(Optional.of(timeout)); } + + /* + * Workaround, see: https://github.com/rzwitserloot/lombok/issues/2623#issuecomment-714816001 + * Consider reverting this workaround if Lombok fixes that issue. + */ + private StartAssertionOptionsBuilder timeout(Long timeout) { + return this.timeout(Optional.ofNullable(timeout)); + } } } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala index 263e4322b..9f4b60bb7 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/Generators.scala @@ -3,6 +3,7 @@ package com.yubico.webauthn import com.yubico.scalacheck.gen.JavaGenerators._ import com.yubico.webauthn.attestation.Attestation import com.yubico.webauthn.attestation.Generators._ +import com.yubico.webauthn.data.AssertionExtensionInputs import com.yubico.webauthn.data.AttestationType import com.yubico.webauthn.data.AuthenticatorAssertionExtensionOutputs import com.yubico.webauthn.data.AuthenticatorRegistrationExtensionOutputs @@ -11,8 +12,10 @@ import com.yubico.webauthn.data.ClientAssertionExtensionOutputs import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs import com.yubico.webauthn.data.Generators._ import com.yubico.webauthn.data.PublicKeyCredentialDescriptor +import com.yubico.webauthn.data.UserVerificationRequirement import org.scalacheck.Arbitrary import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.Gen import java.util.Optional @@ -87,4 +90,24 @@ object Generators { .build() ) + implicit val arbitraryStartAssertionOptions + : Arbitrary[StartAssertionOptions] = Arbitrary( + for { + extensions <- arbitrary[Option[AssertionExtensionInputs]] + timeout <- Gen.option(Gen.posNum[Long]) + usernameOrUserHandle <- arbitrary[Option[Either[String, ByteArray]]] + userVerification <- arbitrary[Option[UserVerificationRequirement]] + } yield { + val b = StartAssertionOptions.builder() + extensions.foreach(b.extensions) + timeout.foreach(b.timeout) + usernameOrUserHandle.foreach { + case Left(username) => b.username(username) + case Right(userHandle) => b.userHandle(userHandle) + } + userVerification.foreach(b.userVerification) + b.build() + } + ) + } diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala index 7bfad5fe7..c46c263ec 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/RelyingPartyStartOperationSpec.scala @@ -25,6 +25,7 @@ package com.yubico.webauthn import com.yubico.internal.util.scala.JavaConverters._ +import com.yubico.webauthn.Generators._ import com.yubico.webauthn.data.AssertionExtensionInputs import com.yubico.webauthn.data.AttestationConveyancePreference import com.yubico.webauthn.data.AuthenticatorAttachment @@ -58,7 +59,8 @@ class RelyingPartyStartOperationSpec with ScalaCheckDrivenPropertyChecks { def credRepo( - credentials: Set[PublicKeyCredentialDescriptor] + credentials: Set[PublicKeyCredentialDescriptor], + userId: UserIdentity, ): CredentialRepository = new CredentialRepository { override def getCredentialIdsForUsername( @@ -68,8 +70,10 @@ class RelyingPartyStartOperationSpec username: String ): Optional[ByteArray] = ??? override def getUsernameForUserHandle( - userHandleBase64: ByteArray - ): Optional[String] = ??? + userHandle: ByteArray + ): Optional[String] = + if (userHandle == userId.getId) Some(userId.getName).asJava + else None.asJava override def lookup( credentialId: ByteArray, userHandle: ByteArray, @@ -84,11 +88,12 @@ class RelyingPartyStartOperationSpec attestationConveyancePreference: Option[AttestationConveyancePreference] = None, credentials: Set[PublicKeyCredentialDescriptor] = Set.empty, + userId: UserIdentity, ): RelyingParty = { var builder = RelyingParty .builder() .identity(rpId) - .credentialRepository(credRepo(credentials)) + .credentialRepository(credRepo(credentials, userId)) .preferredPubkeyParams(List(PublicKeyCredentialParameters.ES256).asJava) .origins(Set.empty.asJava) appId.foreach { appid => builder = builder.appId(appid) } @@ -115,7 +120,7 @@ class RelyingPartyStartOperationSpec it("sets excludeCredentials automatically.") { forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials) + val rp = relyingParty(credentials = credentials, userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() @@ -130,7 +135,7 @@ class RelyingPartyStartOperationSpec } it("sets challenge randomly.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val request1 = rp.startRegistration( StartRegistrationOptions.builder().user(userId).build() @@ -151,7 +156,7 @@ class RelyingPartyStartOperationSpec .requireResidentKey(true) .build() - val pkcco = relyingParty().startRegistration( + val pkcco = relyingParty(userId = userId).startRegistration( StartRegistrationOptions .builder() .user(userId) @@ -168,14 +173,14 @@ class RelyingPartyStartOperationSpec .requireResidentKey(true) .build() - val pkccoWith = relyingParty().startRegistration( + val pkccoWith = relyingParty(userId = userId).startRegistration( StartRegistrationOptions .builder() .user(userId) .authenticatorSelection(Optional.of(authnrSel)) .build() ) - val pkccoWithout = relyingParty().startRegistration( + val pkccoWithout = relyingParty(userId = userId).startRegistration( StartRegistrationOptions .builder() .user(userId) @@ -191,12 +196,13 @@ class RelyingPartyStartOperationSpec it("uses the RelyingParty setting for attestationConveyancePreference.") { forAll { acp: Option[AttestationConveyancePreference] => val pkcco = - relyingParty(attestationConveyancePreference = acp).startRegistration( - StartRegistrationOptions - .builder() - .user(userId) - .build() - ) + relyingParty(attestationConveyancePreference = acp, userId = userId) + .startRegistration( + StartRegistrationOptions + .builder() + .user(userId) + .build() + ) pkcco.getAttestation should equal( acp getOrElse AttestationConveyancePreference.NONE ) @@ -204,7 +210,7 @@ class RelyingPartyStartOperationSpec } it("allows setting the timeout to empty.") { - val pkcco = relyingParty().startRegistration( + val pkcco = relyingParty(userId = userId).startRegistration( StartRegistrationOptions .builder() .user(userId) @@ -215,7 +221,7 @@ class RelyingPartyStartOperationSpec } it("allows setting the timeout to a positive value.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) forAll(Gen.posNum[Long]) { timeout: Long => val pkcco = rp.startRegistration( @@ -266,7 +272,7 @@ class RelyingPartyStartOperationSpec "sets the appidExclude extension if the RP instance is given an AppId." ) { forAll { appId: AppId => - val rp = relyingParty(appId = Some(appId)) + val rp = relyingParty(appId = Some(appId), userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() @@ -279,7 +285,7 @@ class RelyingPartyStartOperationSpec } it("does not set the appidExclude extension if the RP instance is not given an AppId.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() @@ -295,7 +301,7 @@ class RelyingPartyStartOperationSpec println(extensions.getExtensionIds) println(extensions) - val rp = relyingParty() + val rp = relyingParty(userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() @@ -309,7 +315,7 @@ class RelyingPartyStartOperationSpec } it("by default does not set the uvm extension.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() @@ -321,7 +327,7 @@ class RelyingPartyStartOperationSpec it("sets the uvm extension if enabled in StartRegistrationOptions.") { forAll { extensions: RegistrationExtensionInputs => - val rp = relyingParty() + val rp = relyingParty(userId = userId) val result = rp.startRegistration( StartRegistrationOptions .builder() @@ -335,7 +341,7 @@ class RelyingPartyStartOperationSpec } it("respects the requireResidentKey setting.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val pkccoFalse = rp.startRegistration( StartRegistrationOptions @@ -377,7 +383,7 @@ class RelyingPartyStartOperationSpec } it("respects the authenticatorAttachment parameter.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val pkcco = rp.startRegistration( StartRegistrationOptions @@ -430,7 +436,7 @@ class RelyingPartyStartOperationSpec } it("sets requireResidentKey to agree with residentKey.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val pkccoDiscouraged = rp.startRegistration( StartRegistrationOptions @@ -493,9 +499,9 @@ class RelyingPartyStartOperationSpec describe("RelyingParty.startAssertion") { - it("sets allowCredentials to empty if not given a username.") { + it("sets allowCredentials to empty if not given a username nor a user handle.") { forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials) + val rp = relyingParty(credentials = credentials, userId = userId) val result = rp.startAssertion(StartAssertionOptions.builder().build()) result.getPublicKeyCredentialRequestOptions.getAllowCredentials.asScala shouldBe empty @@ -504,7 +510,7 @@ class RelyingPartyStartOperationSpec it("sets allowCredentials automatically if given a username.") { forAll { credentials: Set[PublicKeyCredentialDescriptor] => - val rp = relyingParty(credentials = credentials) + val rp = relyingParty(credentials = credentials, userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -517,6 +523,21 @@ class RelyingPartyStartOperationSpec } } + it("sets allowCredentials automatically if given a user handle.") { + forAll { credentials: Set[PublicKeyCredentialDescriptor] => + val rp = relyingParty(credentials = credentials, userId = userId) + val result = rp.startAssertion( + StartAssertionOptions + .builder() + .userHandle(userId.getId) + .build() + ) + + result.getPublicKeyCredentialRequestOptions.getAllowCredentials.asScala + .map(_.asScala.toSet) should equal(Some(credentials)) + } + } + it("includes transports in allowCredentials when available.") { forAll( Gen.nonEmptyContainerOf[Set, AuthenticatorTransport]( @@ -532,8 +553,8 @@ class RelyingPartyStartOperationSpec cred2: PublicKeyCredentialDescriptor, cred3: PublicKeyCredentialDescriptor, ) => - val rp = relyingParty(credentials = - Set( + val rp = relyingParty( + credentials = Set( cred1.toBuilder.transports(cred1Transports.asJava).build(), cred2.toBuilder .transports( @@ -545,7 +566,8 @@ class RelyingPartyStartOperationSpec Optional.empty[java.util.Set[AuthenticatorTransport]] ) .build(), - ) + ), + userId = userId, ) val result = rp.startAssertion( StartAssertionOptions @@ -567,7 +589,7 @@ class RelyingPartyStartOperationSpec } it("sets challenge randomly.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val request1 = rp.startAssertion(StartAssertionOptions.builder().build()) val request2 = rp.startAssertion(StartAssertionOptions.builder().build()) @@ -579,7 +601,7 @@ class RelyingPartyStartOperationSpec it("sets the appid extension if the RP instance is given an AppId.") { forAll { appId: AppId => - val rp = relyingParty(appId = Some(appId)) + val rp = relyingParty(appId = Some(appId), userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -594,7 +616,7 @@ class RelyingPartyStartOperationSpec } it("does not set the appid extension if the RP instance is not given an AppId.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -608,7 +630,7 @@ class RelyingPartyStartOperationSpec } it("allows setting the timeout to empty.") { - val req = relyingParty().startAssertion( + val req = relyingParty(userId = userId).startAssertion( StartAssertionOptions .builder() .timeout(Optional.empty[java.lang.Long]) @@ -618,7 +640,7 @@ class RelyingPartyStartOperationSpec } it("allows setting the timeout to a positive value.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) forAll(Gen.posNum[Long]) { timeout: Long => val req = rp.startAssertion( @@ -663,7 +685,7 @@ class RelyingPartyStartOperationSpec } it("by default does not set the uvm extension.") { - val rp = relyingParty() + val rp = relyingParty(userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -676,7 +698,7 @@ class RelyingPartyStartOperationSpec it("sets the uvm extension if enabled in StartRegistrationOptions.") { forAll { extensions: AssertionExtensionInputs => - val rp = relyingParty() + val rp = relyingParty(userId = userId) val result = rp.startAssertion( StartAssertionOptions .builder() @@ -691,4 +713,95 @@ class RelyingPartyStartOperationSpec } } + describe("StartAssertionOptions") { + + it("resets username when userHandle is set.") { + forAll { (sao: StartAssertionOptions, userHandle: ByteArray) => + val result = sao.toBuilder.userHandle(userHandle).build() + result.getUsername.asScala shouldBe empty + } + + forAll { (sao: StartAssertionOptions, userHandle: ByteArray) => + val result = sao.toBuilder.userHandle(Some(userHandle).asJava).build() + result.getUsername.asScala shouldBe empty + } + } + + it("resets userHandle when username is set.") { + forAll { (sao: StartAssertionOptions, username: String) => + val result = sao.toBuilder.username(username).build() + result.getUserHandle.asScala shouldBe empty + } + + forAll { (sao: StartAssertionOptions, username: String) => + val result = sao.toBuilder.username(Some(username).asJava).build() + result.getUserHandle.asScala shouldBe empty + } + } + + it("does not reset username when userHandle is set to empty.") { + forAll { (sao: StartAssertionOptions, username: String) => + val result = sao.toBuilder + .username(username) + .userHandle(Optional.empty[ByteArray]) + .build() + result.getUsername.asScala should equal(Some(username)) + } + + forAll { (sao: StartAssertionOptions, username: String) => + val result = sao.toBuilder + .username(username) + .userHandle(null: ByteArray) + .build() + result.getUsername.asScala should equal(Some(username)) + } + } + + it("does not reset userHandle when username is set to empty.") { + forAll { (sao: StartAssertionOptions, userHandle: ByteArray) => + val result = sao.toBuilder + .userHandle(userHandle) + .username(Optional.empty[String]) + .build() + result.getUserHandle.asScala should equal(Some(userHandle)) + } + + forAll { (sao: StartAssertionOptions, userHandle: ByteArray) => + val result = sao.toBuilder + .userHandle(userHandle) + .username(null: String) + .build() + result.getUserHandle.asScala should equal(Some(userHandle)) + } + } + + it("allows unsetting username.") { + forAll { (sao: StartAssertionOptions, username: String) => + val preresult = sao.toBuilder.username(username).build() + preresult.getUsername.asScala should equal(Some(username)) + + val result1 = + preresult.toBuilder.username(Optional.empty[String]).build() + result1.getUsername.asScala shouldBe empty + + val result2 = preresult.toBuilder.username(null: String).build() + result2.getUsername.asScala shouldBe empty + } + } + + it("allows unsetting userHandle.") { + forAll { (sao: StartAssertionOptions, userHandle: ByteArray) => + val preresult = sao.toBuilder.userHandle(userHandle).build() + preresult.getUserHandle.asScala should equal(Some(userHandle)) + + val result1 = + preresult.toBuilder.userHandle(Optional.empty[ByteArray]).build() + result1.getUserHandle.asScala shouldBe empty + + val result2 = preresult.toBuilder.userHandle(null: ByteArray).build() + result2.getUserHandle.asScala shouldBe empty + } + } + } + } diff --git a/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java b/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java index d59bd643d..9a60ced41 100644 --- a/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java +++ b/yubico-util/src/main/java/com/yubico/internal/util/OptionalUtil.java @@ -2,12 +2,25 @@ import java.util.Optional; import java.util.function.BinaryOperator; +import java.util.function.Supplier; import lombok.experimental.UtilityClass; /** Utilities for working with {@link Optional} values. */ @UtilityClass public class OptionalUtil { + /** + * If primary is present, return it unchanged. Otherwise return the result of + * recover. + */ + public static Optional orElseOptional(Optional primary, Supplier> recover) { + if (primary.isPresent()) { + return primary; + } else { + return recover.get(); + } + } + /** * If both a and b are present, return f(a, b). *