app/controllers/PasskeyAuthFilter.scala (85 lines of code) (raw):

package controllers import aws.{PasskeyChallengeDB, PasskeyDB} import com.gu.googleauth.AuthAction.UserIdentityRequest import logic.Passkey import models.JanusException import models.JanusException.throwableWrites import play.api.Logging import play.api.http.Status.{BAD_REQUEST, INTERNAL_SERVER_ERROR} import play.api.libs.json.Json.toJson import play.api.mvc.Results.Status import play.api.mvc.{ActionFilter, AnyContentAsFormUrlEncoded, Result} import software.amazon.awssdk.services.dynamodb.DynamoDbClient import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} /** Performs passkey authentication and only allows an action to continue if * authentication is successful. * * See * [[https://webauthn4j.github.io/webauthn4j/en/#webauthn-assertion-verification-and-post-processing]]. */ class PasskeyAuthFilter(host: String)(implicit dynamoDb: DynamoDbClient, ec: ExecutionContext ) extends ActionFilter[UserIdentityRequest] with Logging { // TODO: Consider a separate EC for passkey processing def executionContext: ExecutionContext = ec def filter[A](request: UserIdentityRequest[A]): Future[Option[Result]] = Future( apiResponse( for { challengeResponse <- PasskeyChallengeDB.loadChallenge(request.user) challenge <- PasskeyChallengeDB.extractChallenge( challengeResponse, request.user ) authData <- extractAuthenticationData(request) credentialResponse <- PasskeyDB.loadCredential( request.user, authData.getCredentialId ) credential <- PasskeyDB.extractCredential( credentialResponse, request.user ) verifiedAuthData <- Passkey.verifiedAuthentication( host, challenge, authData, credential ) _ <- PasskeyChallengeDB.delete(request.user) _ <- PasskeyDB.updateCounter(request.user, verifiedAuthData) _ = logger.info( s"Authenticated passkey for user ${request.user.username}" ) } yield () ) ) private def apiResponse(auth: => Try[Unit]): Option[Result] = auth match { case Failure(err: JanusException) => logger.error(err.engineerMessage, err.causedBy.orNull) Some(Status(err.httpCode)(toJson(err))) case Failure(err) => logger.error(err.getMessage, err) Some(Status(INTERNAL_SERVER_ERROR)(toJson(err))) case Success(_) => None } private def extractAuthenticationData[A](request: UserIdentityRequest[A]) = { def createMissingBodyError(username: String): JanusException = JanusException( userMessage = "Missing authentication credentials", engineerMessage = s"Authentication request for user '$username' is missing required credentials", httpCode = BAD_REQUEST, causedBy = None ) for { body <- request.body match { case AnyContentAsFormUrlEncoded(data) => data .get("credentials") .flatMap(_.headOption) .toRight(createMissingBodyError(request.user.username)) .toTry case _ => Failure(createMissingBodyError(request.user.username)) } authData <- Passkey.parsedAuthentication(body) } yield authData } }