app/logic/Passkey.scala (201 lines of code) (raw):

package logic 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._ import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier import com.webauthn4j.data.client.Origin import com.webauthn4j.data.client.challenge.{Challenge, DefaultChallenge} import com.webauthn4j.server.ServerProperty import com.webauthn4j.verifier.exception.VerificationException import models._ import play.api.http.Status.{BAD_REQUEST, UNAUTHORIZED} import java.net.URI import java.nio.charset.StandardCharsets.UTF_8 import scala.jdk.CollectionConverters._ import scala.util.{Failure, Try} /** Logic for registration of passkeys and authentication using them. */ object Passkey { /* When true, requires the user to verify their identity * (with Touch ID or other authentication method) * before completing registration and authentication. */ private val userVerificationRequired = true private val publicKeyCredentialParameters = List( // ES256 is widely supported and efficient new PublicKeyCredentialParameters( PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256 ), // RS256 for broader compatibility new PublicKeyCredentialParameters( PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256 ), // EdDSA for better security/performance in newer authenticators new PublicKeyCredentialParameters( PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.EdDSA ) ) private val webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager() /** Creates registration options for a new passkey. This is required by a * browser to initiate the registration process. * * See * [[https://webauthn4j.github.io/webauthn4j/en/#generating-a-webauthn-credential-key-pair]]. * * @param appName * Name of the app the passkey will authenticate (the relying party). * @param appHost * The host of the application the passkey will authenticate (the relying * party). * @param user * The user identity retrieved from Google auth. * @param challenge * The challenge to be used for registration. * @return * A PublicKeyCredentialCreationOptions object containing the registration * options. */ def registrationOptions( appName: String, appHost: String, user: UserIdentity, challenge: Challenge = new DefaultChallenge() ): Try[PublicKeyCredentialCreationOptions] = Try { val appDomain = URI.create(appHost).getHost val relyingParty = new PublicKeyCredentialRpEntity(appDomain, appName) val userInfo = new PublicKeyCredentialUserEntity( user.username.getBytes(UTF_8), user.username, user.fullName ) new PublicKeyCredentialCreationOptions( relyingParty, userInfo, challenge, publicKeyCredentialParameters.asJava ) }.recoverWith(exception => Failure( JanusException( userMessage = "Failed to create registration options", engineerMessage = s"Failed to create registration options for user ${user.username}: ${exception.getMessage}", httpCode = BAD_REQUEST, causedBy = Some(exception) ) ) ) /** Options required by a browser to initiate the authentication process. * * See * [[https://webauthn4j.github.io/webauthn4j/en/#generating-a-webauthn-assertion]]. */ def authenticationOptions( user: UserIdentity, challenge: Challenge = new DefaultChallenge() ): Try[PublicKeyCredentialRequestOptions] = Try( new PublicKeyCredentialRequestOptions( challenge, null, null, null, null, null ) ).recoverWith(exception => Failure( JanusException( userMessage = "Failed to create authentication options", engineerMessage = s"Failed to create authentication options for user ${user.username}: ${exception.getMessage}", httpCode = BAD_REQUEST, causedBy = Some(exception) ) ) ) /** Verifies the registration response from the browser. This is called after * the user has completed the passkey creation process on the browser. * * See * [[https://webauthn4j.github.io/webauthn4j/en/#registering-the-webauthn-public-key-credential-on-the-server]]. * * @param appHost * The host of the application the passkey will authenticate (the relying * party). * @param challenge * Must correspond with the challenge passed in [[registrationOptions]]). * @param jsonResponse * The JSON response from the browser containing the registration data. * @return * A CredentialRecord object containing the verified credential data or an * [[IllegalArgumentException]] if verification fails. */ def verifiedRegistration( appHost: String, challenge: Challenge, jsonResponse: String ): Try[CredentialRecord] = Try { val regData = webAuthnManager.parseRegistrationResponseJSON(jsonResponse) val regParams = new RegistrationParameters( new ServerProperty( Origin.create(appHost), URI.create(appHost).getHost, challenge ), publicKeyCredentialParameters.asJava, userVerificationRequired ) val verified = webAuthnManager.verify(regData, regParams) new CredentialRecordImpl( verified.getAttestationObject, verified.getCollectedClientData, verified.getClientExtensions, verified.getTransports ) }.recoverWith { case exception: VerificationException => Failure( JanusException( userMessage = "Registration verification failed", engineerMessage = s"Registration verification failed: ${exception.getMessage}", httpCode = BAD_REQUEST, causedBy = Some(exception) ) ) case exception => Failure( JanusException( userMessage = "Bad arguments for registration verification request", engineerMessage = s"Bad arguments for registration verification request: ${exception.getMessage}", httpCode = BAD_REQUEST, causedBy = Some(exception) ) ) } /** Parses the authentication response from the browser. Call this when the * user has authenticated to the browser using a passkey. * * See * [[https://webauthn4j.github.io/webauthn4j/en/#webauthn-assertion-verification-and-post-processing]]. * * @param jsonResponse * Json response from browser containing authentication data. */ def parsedAuthentication( jsonResponse: String ): Try[AuthenticationData] = Try(webAuthnManager.parseAuthenticationResponseJSON(jsonResponse)) .recoverWith { case err: DataConversionException => Failure( JanusException( userMessage = "Authentication parsing failed", engineerMessage = s"Authentication parsing failed: ${err.getMessage}", httpCode = BAD_REQUEST, causedBy = Some(err) ) ) case err => Failure( JanusException( userMessage = "Bad authentication object", engineerMessage = s"Bad authentication object submitted: ${err.getMessage}", httpCode = BAD_REQUEST, causedBy = Some(err) ) ) } /** Verifies the authentication response from the browser. Call this when the * user has authenticated to the browser using a passkey. * * See * [[https://webauthn4j.github.io/webauthn4j/en/#webauthn-assertion-verification-and-post-processing]]. * * @param challenge * Must correspond with the challenge passed in [[authenticationOptions]]). * @param authenticationData * The parsed authentication data supplied by the browser. */ def verifiedAuthentication( appHost: String, challenge: Challenge, authenticationData: AuthenticationData, credentialRecord: CredentialRecord ): Try[AuthenticationData] = Try { val authParams = new AuthenticationParameters( new ServerProperty( Origin.create(appHost), URI.create(appHost).getHost, challenge ), credentialRecord, List(authenticationData.getCredentialId).asJava, userVerificationRequired ) webAuthnManager.verify(authenticationData, authParams) }.recoverWith { case err: VerificationException => Failure( JanusException( userMessage = "Authentication verification failed", engineerMessage = s"Authentication verification failed: ${err.getMessage}", httpCode = UNAUTHORIZED, causedBy = Some(err) ) ) case err => Failure( JanusException( userMessage = "Bad arguments for authentication verification", engineerMessage = s"Bad arguments for authentication verification: ${err.getMessage}", httpCode = BAD_REQUEST, causedBy = Some(err) ) ) } }