diff --git a/NEWS b/NEWS index add4060bb..6c8660ee9 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,11 @@ +== Version 1.3.0 (unreleased) == + +New features: + +* New optional parameter `timeout` added to `StartRegistrationOptions` and + `StartAssertionOptions` + + == Version 1.2.0 == New features: 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 b40c51162..6d521d6ff 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 @@ -292,6 +292,7 @@ public PublicKeyCredentialCreationOptions startRegistration(StartRegistrationOpt ) .authenticatorSelection(startRegistrationOptions.getAuthenticatorSelection()) .extensions(startRegistrationOptions.getExtensions()) + .timeout(startRegistrationOptions.getTimeout()) ; attestationConveyancePreference.ifPresent(builder::attestation); return builder.build(); @@ -344,6 +345,7 @@ public AssertionRequest startAssertion(StartAssertionOptions startAssertionOptio .appid(appId) .build() ) + .timeout(startAssertionOptions.getTimeout()) ; startAssertionOptions.getUserVerification().ifPresent(pkcro::userVerification); 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 5d958f020..e503be09f 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 @@ -80,9 +80,19 @@ public class StartAssertionOptions { @NonNull private final Optional userVerification; + /** + * The value for {@link PublicKeyCredentialRequestOptions#getTimeout()} for this authentication operation. + *

+ * The default is empty. + *

+ */ + @NonNull + private final Optional timeout; + public static class StartAssertionOptionsBuilder { private @NonNull Optional username = Optional.empty(); private @NonNull Optional userVerification = Optional.empty(); + private @NonNull Optional timeout = Optional.empty(); /** * The username of the user to authenticate, if the user has already been identified. @@ -141,5 +151,29 @@ public StartAssertionOptionsBuilder userVerification(@NonNull Optional + * The default is empty. + *

+ */ + public StartAssertionOptionsBuilder timeout(@NonNull Optional timeout) { + if (timeout.isPresent() && timeout.get() <= 0) { + throw new IllegalArgumentException("timeout must be positive, was: " + timeout.get()); + } + this.timeout = timeout; + return this; + } + + /** + * The value for {@link PublicKeyCredentialRequestOptions#getTimeout()} for this authentication operation. + *

+ * The default is empty. + *

+ */ + public StartAssertionOptionsBuilder timeout(long timeout) { + return this.timeout(Optional.of(timeout)); + } } } diff --git a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java index 3660c86c9..f2f87eed3 100644 --- a/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java +++ b/webauthn-server-core/src/main/java/com/yubico/webauthn/StartRegistrationOptions.java @@ -25,6 +25,7 @@ package com.yubico.webauthn; import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; import com.yubico.webauthn.data.RegistrationExtensionInputs; import com.yubico.webauthn.data.UserIdentity; import java.util.Optional; @@ -58,12 +59,22 @@ public class StartRegistrationOptions { @Builder.Default private final RegistrationExtensionInputs extensions = RegistrationExtensionInputs.builder().build(); + /** + * The value for {@link PublicKeyCredentialCreationOptions#getTimeout()} for this registration operation. + *

+ * The default is empty. + *

+ */ + @NonNull + private final Optional timeout; + public static StartRegistrationOptionsBuilder.MandatoryStages builder() { return new StartRegistrationOptionsBuilder.MandatoryStages(); } public static class StartRegistrationOptionsBuilder { private @NonNull Optional authenticatorSelection = Optional.empty(); + private @NonNull Optional timeout = Optional.empty(); public static class MandatoryStages { private final StartRegistrationOptionsBuilder builder = new StartRegistrationOptionsBuilder(); @@ -87,6 +98,30 @@ public StartRegistrationOptionsBuilder authenticatorSelection(@NonNull Optional< public StartRegistrationOptionsBuilder authenticatorSelection(@NonNull AuthenticatorSelectionCriteria authenticatorSelection) { return this.authenticatorSelection(Optional.of(authenticatorSelection)); } + + /** + * The value for {@link PublicKeyCredentialCreationOptions#getTimeout()} for this registration operation. + *

+ * The default is empty. + *

+ */ + public StartRegistrationOptionsBuilder timeout(@NonNull Optional timeout) { + if (timeout.isPresent() && timeout.get() <= 0) { + throw new IllegalArgumentException("timeout must be positive, was: " + timeout.get()); + } + this.timeout = timeout; + return this; + } + + /** + * The value for {@link PublicKeyCredentialCreationOptions#getTimeout()} for this registration operation. + *

+ * The default is empty. + *

+ */ + public StartRegistrationOptionsBuilder timeout(long timeout) { + return this.timeout(Optional.of(timeout)); + } } } 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 df7339445..5d61940dd 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 @@ -28,6 +28,8 @@ import java.util.Optional import com.yubico.internal.util.scala.JavaConverters._ import com.yubico.scalacheck.gen.JavaGenerators._ +import com.yubico.webauthn.data.AuthenticatorAttachment +import com.yubico.webauthn.data.AuthenticatorSelectionCriteria import com.yubico.webauthn.data.PublicKeyCredentialDescriptor import com.yubico.webauthn.data.ByteArray import com.yubico.webauthn.data.UserIdentity @@ -38,6 +40,7 @@ import com.yubico.webauthn.extension.appid.AppId import com.yubico.webauthn.extension.appid.Generators._ import org.junit.runner.RunWith import org.scalacheck.Arbitrary._ +import org.scalacheck.Gen import org.scalatest.FunSpec import org.scalatest.Matchers import org.scalatest.junit.JUnitRunner @@ -104,6 +107,56 @@ class RelyingPartyStartOperationSpec extends FunSpec with Matchers with Generato request2.getChallenge.size should be >= 32 } + it("allows setting the timeout to empty.") { + val pkcco = relyingParty().startRegistration( + StartRegistrationOptions.builder() + .user(userId) + .timeout(Optional.empty[java.lang.Long]) + .build()) + pkcco.getTimeout.asScala shouldBe 'empty + } + + it("allows setting the timeout to a positive value.") { + val rp = relyingParty() + + forAll(Gen.posNum[Long]) { timeout: Long => + val pkcco = rp.startRegistration( + StartRegistrationOptions.builder() + .user(userId) + .timeout(timeout) + .build()) + + pkcco.getTimeout.asScala should equal (Some(timeout)) + } + } + + it("does not allow setting the timeout to zero or negative.") { + an [IllegalArgumentException] should be thrownBy { + StartRegistrationOptions.builder() + .user(userId) + .timeout(0) + } + + an [IllegalArgumentException] should be thrownBy { + StartRegistrationOptions.builder() + .user(userId) + .timeout(Optional.of[java.lang.Long](0L)) + } + + forAll(Gen.negNum[Long]) { timeout: Long => + an [IllegalArgumentException] should be thrownBy { + StartRegistrationOptions.builder() + .user(userId) + .timeout(timeout) + } + + an [IllegalArgumentException] should be thrownBy { + StartRegistrationOptions.builder() + .user(userId) + .timeout(Optional.of[java.lang.Long](timeout)) + } + } + } } describe("RelyingParty.startAssertion") { @@ -152,6 +205,50 @@ class RelyingPartyStartOperationSpec extends FunSpec with Matchers with Generato } } + it("allows setting the timeout to empty.") { + val req = relyingParty().startAssertion( + StartAssertionOptions.builder() + .timeout(Optional.empty[java.lang.Long]) + .build()) + req.getPublicKeyCredentialRequestOptions.getTimeout.asScala shouldBe 'empty + } + + it("allows setting the timeout to a positive value.") { + val rp = relyingParty() + + forAll(Gen.posNum[Long]) { timeout: Long => + val req = rp.startAssertion( + StartAssertionOptions.builder() + .timeout(timeout) + .build()) + + req.getPublicKeyCredentialRequestOptions.getTimeout.asScala should equal (Some(timeout)) + } + } + + it("does not allow setting the timeout to zero or negative.") { + an [IllegalArgumentException] should be thrownBy { + StartAssertionOptions.builder() + .timeout(0) + } + + an [IllegalArgumentException] should be thrownBy { + StartAssertionOptions.builder() + .timeout(Optional.of[java.lang.Long](0L)) + } + + forAll(Gen.negNum[Long]) { timeout: Long => + an [IllegalArgumentException] should be thrownBy { + StartAssertionOptions.builder() + .timeout(timeout) + } + + an [IllegalArgumentException] should be thrownBy { + StartAssertionOptions.builder() + .timeout(Optional.of[java.lang.Long](timeout)) + } + } + } } }