import groovy.json.JsonBuilder
import groovy.json.JsonSlurper
import java.util.UUID

if (inargs.containsKey('cancel_fido2')) {
    response.setResult('cancel')
    LOG.debug("Fido2Auth: authentication cancelled by user")
    return
}

def base64url(uuid) {
  def msb = uuid.getMostSignificantBits()
  def lsb = uuid.getLeastSignificantBits()
  return new byte[] {
       (byte) msb,
       (byte) (msb >> 8),
       (byte) (msb >> 16),
       (byte) (msb >> 24),
       (byte) (msb >> 32),
       (byte) (msb >> 40),
       (byte) (msb >> 48),
       (byte) (msb >> 56),
       (byte) lsb,
       (byte) (lsb >> 8),
       (byte) (lsb >> 16),
       (byte) (lsb >> 24),
       (byte) (lsb >> 32),
       (byte) (lsb >> 40),
       (byte) (lsb >> 48),
       (byte) (lsb >> 56)
    }.encodeBase64Url().toString()
}

def showGui() {
    response.setGuiName('fido2_auth') // name is the trigger for including the JS
    response.setGuiLabel('title.login.fido2')
    response.addInfoGuiField('info', 'info.login.fido2', null)
    response.addHiddenGuiField('authRequestId', 'not used', session['ch.nevis.auth.saml.request.id'])
    response.addTextGuiField('email', 'email', session['ch.nevis.idm.User.email'])
    if (notes.containsKey('lasterrorinfo') || notes.containsKey('lasterror')) {
        response.addErrorGuiField('lasterror', notes['lasterrorinfo'], notes['lasterror'])
    }
    if (parameters.containsKey('cancel')) {
        response.addButtonGuiField('cancel_fido2', 'cancel.login.fido2.button.label', 'true')
    }
}

def getPath() {
    if (inargs.containsKey('path')) { // form POST
        return inargs['path']
    }
    if (inargs.containsKey('o.path.v')) { // AJAX POST
        return inargs['o.path.v']
    }
    return null
}

def post(connection, json) {
    connection.setRequestMethod("POST")
    connection.setRequestProperty("Content-Type", "application/json")
    connection.setDoOutput(true) // required to write body
    String body = json.toString()
    LOG.debug("Fido2Auth: ==> Request: '${body}'")
    connection.getOutputStream().write(body.getBytes())
}

String userExtId = session['ch.adnovum.nevisidm.user.extId'] ?: session['ch.nevis.idm.User.extId'] ?: request.getUserId() ?: notes['userid']
if (userExtId == null) {
    LOG.error("Fido2Auth: missing extId of nevisIDM user. check your authentication flow.")
}
// without the user extId this script won't work and we can fail with a System Error
Objects.requireNonNull(userExtId)

def path = getPath()
if (path == null) {
    showGui() // POST from JavaScript not received
    return
}

def connection = null
try {
  def fullPath = "https://${parameters.get('fido')}${path}"
  LOG.debug("Fido2Auth: opening connection to '${fullPath}'")
  connection = new URL(fullPath).openConnection()
} catch (Exception e) {
  LOG.error("Fido2Auth: opening connection failed", e)
  notes.setProperty('lasterrorinfo', 'FIDO2 authentication failed')
  response.setResult('error')
  return
}

def json = new JsonBuilder()

if (path == '/nevisfido/fido2/attestation/options') {
    json {
        "username" userExtId
        "userVerification" "required"
    }
    post(connection, json)
    def responseCode = connection.responseCode
    def responseText = responseCode == 200 ? connection.inputStream.text : '{"allowCredentials":[]}'
    def jsonResponse = new JsonSlurper().parseText(responseText)
    def numOfKeys = jsonResponse.allowCredentials ? jsonResponse.allowCredentials.size() : 0

    // non existing account, account without FIDO2 key , or account with disabled FIDO2 key case
    if (responseCode == 404 || responseCode == 400 || numOfKeys == 0) {

      LOG.debug("Fido2Auth: <== Response: ${responseCode}")

      // Accounting
      def requester = session['ch.nevis.auth.saml.request.scoping.requesterId'] ?: 'unknown'
      def requestId = session['ch.nevis.auth.saml.request.id'] ?: 'unknown'
      def requestedAq = session['agov.requestedRoleLevel'] ?: 'unknown'
      def user = session['ch.adnovum.nevisidm.user.extId'] ?: 'unknown'
      def credentialType = session['authenticatedWith'] ?: 'unknown'
      def sourceIp = request.getLoginContext()['connection.HttpHeader.X-Real-IP'] ?: 'unknown'
      def userAgent = request.getLoginContext()['connection.HttpHeader.user-agent'] ?: request.getLoginContext()['connection.HttpHeader.User-Agent'] ?: 'unknown'
      def tAuth = System.currentTimeMillis() - (request.getSession(true).getCreationTime().getEpochSecond() * 1000)
      def details = "no account (404)"
      if (responseCode == 400 ) {
        details = "no fido2 keys for account (400)"    
      } else if (responseCode == 200) {
        details = "no active fido2 key for account (200, empty allowCredentials array)"    
      }
      
      LOG.info("Event='NOACCOUNT', Requester='${requester}', RequestId='${requestId}', RequestedAq=${requestedAq}, User=${session['ch.nevis.idm.User.email']}, CredentialType='${credentialType}', tAuth=${tAuth}ms, SourceIp=${sourceIp}, UserAgent='${userAgent}', Details='${details}'")

      // returning a fake options structure, which shouldn't leak whether the user account exists or not
      // keyId is unique per environment and email, fido2SessionId and challenge are renewed each time
      def keyId = UUID.nameUUIDFromBytes("${parameters['rpId']}.${session['ch.nevis.idm.User.email']}".getBytes())
      responseText = """{"status": "ok",
                         "errorMessage": "",
                         "fido2SessionId": "${UUID.randomUUID()}",
                         "challenge": "${base64url(UUID.randomUUID())}",
                         "timeout": 300000,
                         "rpId": "${parameters['rpId']}",
                         "allowCredentials": [
                           {
                              "type": "public-key",
                              "id": "${base64url(keyId)}",
                              "transports": []
                           }
                         ],
                         "userVerification": "required"}"""
    }

    LOG.debug("Fido2Auth: <== Response: ${responseCode} : ${responseText}")
    response.setContent(responseText)
    response.setContentType('application/json')
    response.setHttpStatusCode(200)
    response.setIsDirectResponse(true)
    return
}

if (path == '/nevisfido/fido2/assertion/result') {

  if (inargs.containsKey('authRequestId') && (inargs['authRequestId'] != session['ch.nevis.auth.saml.request.id'])) {
    // wrong request, "force" a timeout
    LOG.debug('Fido2Auth: authentication timeout enforced, due to concurrent requests')

    response.setIsDirectResponse(true)
    response.setContentType('text/html; charset=UTF-8')
    response.setContent('Timeout')
    response.setHttpStatusCode(205)
    response.setHeader('IDP-AUTH', 'Timeout')

    // CONTINUE to keep the other request beeing processed
    response.setStatus(AuthResponse.AUTH_CONTINUE)
    return
  }

    def userHandleValue = userExtId.getBytes().encodeBase64Url().toString()
    LOG.debug("Fido2Auth: encoded userHandle: ${userHandleValue}")
    json {
        "id" inargs['id']
        "type" inargs['type']
        response {
            "clientDataJSON" inargs['response.clientDataJSON']
            "authenticatorData" inargs['response.authenticatorData']
            "signature" inargs['response.signature']
            "userHandle" userHandleValue
        }
    }
    post(connection, json)
    def responseCode = connection.responseCode
    // test if credentials exist
    if (responseCode != 400) {
        def responseText = connection.inputStream.text
        LOG.debug("Fido2Auth: <== Response: ${responseCode} : ${responseText}")
        if (responseCode == 200 && new JsonSlurper().parseText(responseText).status == 'ok') {
            response.setResult('ok')
            return
        }
    }
    //response.setHttpStatusCode(400)
    //response.setIsDirectResponse(true)
    // DEFINE how to handel error
    notes.setProperty('lasterror', '1')
    notes.setProperty('lasterrorinfo', 'FIDO2 authentication failed')
    response.setResult('error')
    return
}

response.setError(1, "FIDO2 authentication failed")
showGui()