8000 refactor: Use webauthn library native JSON encodings by kelvin-chappell · Pull Request #579 · guardian/janus-app · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

refactor: Use webauthn library native JSON encodings #579

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 10 additions & 18 deletions app/controllers/PasskeyController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import com.gu.janus.model.JanusData
import logic.AccountOrdering.orderedAccountAccess
import logic.UserAccess.{userAccess, username}
import logic.{Date, Favourites, Passkey}
import models.JanusException
import models.JanusException.throwableWrites
import models.PasskeyEncodings._
import play.api.http.Writeable
import models.{JanusException, PasskeyEncodings}
import play.api.http.MimeTypes
import play.api.libs.json.Json.toJson
import play.api.libs.json._
import play.api.mvc._
import play.api.{Logging, Mode}
import play.twirl.api.Html
import software.amazon.awssdk.services.dynamodb.DynamoDbClient

import java.time.format.DateTimeFormatter
Expand Down Expand Up @@ -48,28 +47,21 @@ class PasskeyController(
case Mode.Prod => "Janus-Prod"
}

private def apiResponse[A](
action: => Try[A]
)(implicit resultConverter: A => Result): Result =
private def apiResponse[A](action: => Try[A]): Result =
action match {
case Failure(err: JanusException) =>
logger.error(err.engineerMessage, err.causedBy.orNull)
Status(err.httpCode)(toJson(err))
case Failure(err) =>
logger.error(err.getMessage, err)
Status(INTERNAL_SERVER_ERROR)(toJson(err))
case Success(a) => resultConverter(a)
case Success(result: Result) => result
case Success(html: Html) => Ok(html)
case Success(a) =>
val json = PasskeyEncodings.mapper.writeValueAsString(a)
Ok(json).as(MimeTypes.JSON)
}

implicit def jsonToResult[A](a: A)(implicit writes: Writes[A]): Result = Ok(
toJson(a)
)

implicit def htmlToResult[A](a: A)(implicit writeable: Writeable[A]): Result =
Ok(a)

implicit def resultToResult(r: Result): Result = r

/** See
* [[https://webauthn4j.github.io/webauthn4j/en/#generating-a-webauthn-credential-key-pair]].
*/
Expand Down Expand Up @@ -133,7 +125,7 @@ class PasskeyController(
def authenticationOptions: Action[Unit] = authAction(parse.empty) { request =>
apiResponse(
for {
options <- Passkey.authenticationOptions(request.user)
options <- Passkey.authenticationOptions(host, request.user)
_ <- PasskeyChallengeDB.insert(
UserChallenge(request.user, options.getChallenge)
)
Expand Down
55 changes: 42 additions & 13 deletions app/logic/Passkey.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ import com.gu.googleauth.UserIdentity
import com.webauthn4j.WebAuthnManager
import com.webauthn4j.converter.exception.DataConversionException
import com.webauthn4j.credential.{CredentialRecord, CredentialRecordImpl}
import com.webauthn4j.data.AttestationConveyancePreference.NONE
import com.webauthn4j.data.UserVerificationRequirement.REQUIRED
import com.webauthn4j.data._
import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier
import com.webauthn4j.data.client.Origin
import com.webauthn4j.data.client.challenge.{Challenge, DefaultChallenge}
import com.webauthn4j.data.extension.client._
import com.webauthn4j.server.ServerProperty
import com.webauthn4j.verifier.exception.VerificationException
import models._

import java.net.URI
import java.nio.charset.StandardCharsets.UTF_8
import scala.concurrent.duration.{Duration, SECONDS}
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Try}

Expand All @@ -26,7 +30,13 @@ object Passkey {
*/
private val userVerificationRequired = true

// In order of algorithms we prefer
private val publicKeyCredentialParameters = List(
// EdDSA for better security/performance in newer authenticators
new PublicKeyCredentialParameters(
PublicKeyCredentialType.PUBLIC_KEY,
COSEAlgorithmIdentifier.EdDSA
),
// ES256 is widely supported and efficient
new PublicKeyCredentialParameters(
PublicKeyCredentialType.PUBLIC_KEY,
Expand All @@ -36,11 +46,6 @@ object Passkey {
new PublicKeyCredentialParameters(
PublicKeyCredentialType.PUBLIC_KEY,
COSEAlgorithmIdentifier.RS256
),
// EdDSA for better security/performance in newer authenticators
new PublicKeyCredentialParameters(
PublicKeyCredentialType.PUBLIC_KEY,
COSEAlgorithmIdentifier.EdDSA
)
)

Expand Down Expand Up @@ -79,11 +84,25 @@ object Passkey {
user.username,
user.fullName
)
val timeout = Duration(10, SECONDS)
val excludeCredentials: Seq[PublicKeyCredentialDescriptor] = Nil
val authenticatorSelection: AuthenticatorSelectionCriteria = null
val hints: Seq[PublicKeyCredentialHints] = Nil
val attestation: AttestationConveyancePreference = NONE
val extensions: AuthenticationExtensionsClientInputs[
RegistrationExtensionClientInput
] = null
new PublicKeyCredentialCreationOptions(
relyingParty,
userInfo,
challenge,
publicKeyCredentialParameters.asJava
publicKeyCredentialParameters.asJava,
timeout.toMillis,
excludeCredentials.asJava,
authenticatorSelection,
hints.asJava,
attestation,
extensions
)
}.recoverWith(exception =>
Failure(
Expand All @@ -101,19 +120,29 @@ object Passkey {
* [[https://webauthn4j.github.io/webauthn4j/en/#generating-a-webauthn-assertion]].
*/
def authenticationOptions(
appHost: String,
user: UserIdentity,
challenge: Challenge = new DefaultChallenge()
): Try[PublicKeyCredentialRequestOptions] =
Try(
Try {
val timeout = Duration(10, SECONDS)
val rpId = URI.create(appHost).getHost
val allowCredentials = Nil
val userVerification = REQUIRED
val hints = Nil
val extensions: AuthenticationExtensionsClientInputs[
AuthenticationExtensionClientInput
] = null
new PublicKeyCredentialRequestOptions(
challenge,
null,
null,
null,
null,
null
timeout.toMillis,
rpId,
allowCredentials.asJava,
userVerification,
hints.asJava,
extensions
)
).recoverWith(exception =>
}.recoverWith(exception =>
Failure(
JanusException.invalidFieldInRequest(
user,
Expand Down
89 changes: 27 additions & 62 deletions app/models/Passkey.scala
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package models

import com.webauthn4j.data._
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind._
import com.fasterxml.jackson.databind.module.SimpleModule
import com.webauthn4j.data.attestation.authenticator.AAGUID
import com.webauthn4j.data.client.challenge.Challenge
import com.webauthn4j.data.client.challenge.DefaultChallenge
import com.webauthn4j.util.Base64UrlUtil
import play.api.libs.json._

import java.time.Instant
import scala.jdk.CollectionConverters._

case class PasskeyMetadata(
id: String,
Expand All @@ -18,64 +18,29 @@ case class PasskeyMetadata(
lastUsedTime: Option[Instant]
)

/** Encodings for the WebAuthn data types used in passkey registration and
* authentication. These can't be auto-encoded because they aren't case
* classes.
*/
object PasskeyEncodings {

implicit val relyingPartyWrites: Writes[PublicKeyCredentialRpEntity] =
Writes { rp =>
Json.obj(
"id" -> rp.getId,
"name" -> rp.getName
)
}

implicit val userInfoWrites: Writes[PublicKeyCredentialUserEntity] =
Writes { user =>
Json.obj(
"id" -> Base64UrlUtil.encodeToString(user.getId),
"name" -> user.getName,
"displayName" -> user.getDisplayName
)
}

implicit val publicKeyCredentialParametersWrites
: Writes[PublicKeyCredentialParameters] =
Writes { param =>
Json.obj(
"type" -> param.getType.getValue,
"alg" -> param.getAlg.getValue
)
}

implicit val challengeWrites: Writes[Challenge] =
Writes { challenge =>
JsString(Base64UrlUtil.encodeToString(challenge.getValue))
}

implicit val publicKeyCredentialParametersListWrites
: Writes[java.util.List[PublicKeyCredentialParameters]] =
Writes { paramsList =>
JsArray(paramsList.asScala.map(Json.toJson(_)).toSeq)
}

implicit val creationOptionsWrites
: Writes[PublicKeyCredentialCreationOptions] =
Writes { options =>
Json.obj(
"challenge" -> options.getChallenge,
"rp" -> options.getRp,
"user" -> options.getUser,
"pubKeyCredParams" -> options.getPubKeyCredParams
)
}

implicit val requestOptionsWrites: Writes[PublicKeyCredentialRequestOptions] =
Writes { options =>
Json.obj(
"challenge" -> options.getChallenge
)
}
/*
* As the webauthn library uses Jackson annotations to generate JSON encodings,
* we might as well use them instead of generating our own JSON models.
* This will help with futureproofing.
*/
val mapper: ObjectMapper = {
val mapper = new ObjectMapper()
val module = new SimpleModule()
/*
* Serialize just the value of the challenge instead of a nested object.
* Not sure why this hasn't been encoded in the form 9E88 the webauthn spec expects.
*/
module.addSerializer(
classOf[DefaultChallenge],
(
challenge: DefaultChallenge,
gen: JsonGenerator,
_: SerializerProvider
) => gen.writeString(Base64UrlUtil.encodeToString(challenge.getValue))
)
mapper.registerModule(module)
mapper
}
}
25 changes: 16 additions & 9 deletions test/logic/PasskeyTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ package logic

import com.gu.googleauth.UserIdentity
import com.webauthn4j.data.client.challenge.DefaultChallenge
import models.PasskeyEncodings._
import models.PasskeyEncodings
import org.scalatest.EitherValues
import org.scalatest.freespec.AnyFreeSpec
import org.scalatest.matchers.should
import play.api.libs.json.Json.toJson
import play.api.libs.json._

import java.nio.charset.StandardCharsets.UTF_8

Expand All @@ -32,9 +30,11 @@ class PasskeyTest extends AnyFreeSpec with should.Matchers with EitherValues {
testUser,
challenge = new DefaultChallenge("challenge".getBytes(UTF_8))
)
Json.prettyPrint(toJson(options.toEither.value)) shouldBe
val json = PasskeyEncodings.mapper
.writerWithDefaultPrettyPrinter()
.writeValueAsString(options.toEither.value)
json shouldBe
"""{
| "challenge" : "Y2hhbGxlbmdl",
| "rp" : {
| "id" : "test.example.com",
| "name" : "Janus-Test"
Expand All @@ -44,16 +44,23 @@ class PasskeyTest extends AnyFreeSpec with should.Matchers with EitherValues {
| "name" : "test.user",
| "displayName" : "Test User"
| },
| "challenge" : "Y2hhbGxlbmdl",
| "pubKeyCredParams" : [ {
| "type" : "public-key",
| "alg" : -7
| "alg" : -8
| }, {
| "type" : "public-key",
| "alg" : -257
| "alg" : -7
| }, {
| "type" : "public-key",
| "alg" : -8
| } ]
| "alg" : -257
| } ],
| "timeout" : 10000,
| "excludeCredentials" : [ ],
| "authenticatorSelection" : null,
| "hints" : [ ],
| "attestation" : "none",
| "extensions" : null
|}""".stripMargin
}
}
Expand Down
0