import ch.nevis.esauth.auth.engine.AuthResponse import ch.nevis.esauth.sess.Session import ch.nevis.esauth.util.httpclient.api.HttpClient import groovy.json.JsonSlurper import io.opentelemetry.api.trace.Span import java.time.LocalDate import java.time.ZoneId import java.time.ZoneOffset import com.fasterxml.uuid.Generators def getHeader(String name) { def inctx = request.getLoginContext() // case-insensitive lookup of HTTP headers def map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER) map.putAll(inctx) return map['connection.HttpHeader.' + name] } // returns true on success and false on failure def getNewVerification(Session sess, HttpClient httpClient, String verification_request_template, String traceparent){ // Initialize the verification session on the verifier def endPoint = "${parameters.get('eidVerifierBaseUrl')}/api/v1/verifications" try { def httpResponse = Http.post() .url(endPoint) .header("Accept", "application/json") .header("traceparent", traceparent) .entity(Http.entity() .content(verification_request_template.replaceAll("\\{\\{UUID}}", UUID.randomUUID().toString())) .contentType("application/json") .build()) .build() .send(httpClient) if (httpResponse.code() != 200) { LOG.debug("Result: ${httpResponse}") return false } def json = new JsonSlurper().parseText(httpResponse.bodyAsString()) LOG.debug("Result: ${json}") sess.setAttribute('agov.eid.verification', 'true') sess.setAttribute('agov.eid.verification.id', json.id) sess.setAttribute('agov.eid.verification.link', json.verification_url) // TODO/aca/2025-04-04:This could probably also be INITIATED, once the verifier supports this status if (json.state != 'PENDING') { return false } } catch (Exception e) { LOG.error("Eid verification failed: $e") return false } return true } def clearEidSession(){ def s = request.getAuthSession(true) s.removeAttribute('agov.eid.verification') s.removeAttribute('agov.eid.verification.id') s.removeAttribute('agov.eid.verification.link') } def verification_request_template = ''' { "presentation_definition": { "id": "{{UUID}}", "name": "AGOV Verification", "purpose": "AGOV Login", "format": { "vc+sd-jwt": { "sd-jwt_alg_values": [ "ES256" ], "kb-jwt_alg_values": [ "ES256" ] } }, "input_descriptors": [ { "id": "agov-all-attributes", "name": "AGOV Identity Verification", "purpose": "verification and authentication", "format": { "vc+sd-jwt": { "sd-jwt_alg_values": [ "ES256" ], "kb-jwt_alg_values": [ "ES256" ] } }, "constraints": { "fields": [ { "path": [ "$.family_name" ] }, { "path": [ "$.given_name" ] }, { "path": [ "$.birth_date" ] }, { "path": [ "$.sex" ] }, { "path": [ "$.place_of_origin" ] }, { "path": [ "$.birth_place" ] }, { "path": [ "$.nationality" ] }, { "path": [ "$.personal_administrative_number" ] }, { "path": [ "$.document_number" ] }, { "path": [ "$.issuance_date" ] }, { "path": [ "$.expiry_date" ] }, { "path": [ "$.issuing_authority" ] }, { "path": [ "$.issuing_country" ] } ] } } ] } } ''' def ERROR_CODE_TO_STATUS_MAPPER = [ 'CREDENTIAL_INVALID' : 'FAILED', 'JWT_EXPIRED' : 'ERROR', 'INVALID_FORMAT' : 'ERROR', 'CREDENTIAL_EXPIRED' : 'FAILED', 'MISSING_NONCE' : 'ERROR', 'UNSUPPORTED_FORMAT' : 'ERROR', 'CREDENTIAL_REVOKED' : 'FAILED', 'CREDENTIAL_SUSPENDED' : 'FAILED', 'HOLDER_BINDING_MISMATCH' : 'ERROR', 'CREDENTIAL_MISSING_DATA' : 'FAILED', 'UNRESOLVABLE_STATUS_LIST' : 'ERROR', 'PUBLIC_KEY_OF_ISSUER_UNRESOLVABLE': 'ERROR', 'CLIENT_REJECTED' : 'CANCELED', 'ISSUER_NOT_ACCEPTED' : 'ERROR' ] // --------------- // check, whether we are still processing the correct AuthnRequest // or if the frontend requested a timeout if ( (inargs.containsKey('authRequestId') && (inargs['authRequestId'] != session['ch.nevis.auth.saml.request.id'])) || inargs['oid4vp'] == 'TIMEOUT') { // wrong request, "force" a timeout LOG.debug('authentication timeout enforced, due to concurrent requests (authRequestId missmatch) -> return a 408') 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 sess = request.getAuthSession(true) if (inargs['oid4vp'] == 'ERROR') { LOG.debug("oid4vp error") response.setResult('error') return } if (inargs['oid4vp'] == 'SUCCEEDED') { LOG.debug("oid4vp succeeded") response.setResult('ok') return } // switch to access App if (inargs['accessApp'] == 'accessApp') { //TODO/aca/2025/06/19: In theory we could also land here when we send 'SUCCESS' to the frontend -> would be better to clear all session vaiables that can be set in this Authstate //TODO/aca/2025/06/19: Should we here rather set the LOGINMETHOD cookie and send an error assertion, since otherwise we might swich states too often and Nevis will kill the session? clearEidSession() LOG.debug("Switch to Access App") sess.setAttribute('agov.lastLoginMethod', 'accessApp') response.setResult('agovLogin') return } // switch to fido2 if (inargs['securityKey'] == 'securityKey') { clearEidSession() LOG.debug("Switch to Security Key") sess.setAttribute('agov.lastLoginMethod', 'securityKey') response.setResult('agovLogin') return } // switch to registration if (inargs['fallback'] == 'register') { clearEidSession() LOG.debug("Switch to registration") response.setResult('register') return } HttpClient httpClient = HttpClients.create(parameters) def spanCtxt = Span.current().getSpanContext() def traceparent = "00-${spanCtxt.getTraceId()}-${spanCtxt.getSpanId()}-${spanCtxt.getTraceFlags().asHex()}" if (getHeader('Content-Type') == 'application/json' && inargs.containsKey('o.id.v')) { LOG.debug("Request Status Update") // request for a status update from the verifier def result // FE requested a new verification if (inargs['o.id.v'] == 'NEW' || inargs['o.id.v'] == 'RESET') { LOG.debug("Initializing new verification") if(!getNewVerification(sess, httpClient, verification_request_template, traceparent)){ response.setResult('error') return } } def idvalue = (!inargs['o.id.v'] || inargs['o.id.v'] == 'NEW' || inargs['o.id.v'] == 'RESET') ? session['agov.eid.verification.id'] : inargs['o.id.v'] LOG.error("IDValSent: " + idvalue) // check, whether we are still processing the same verification request or if a new one was generated in e.g. another Tab if(inargs['o.id.v'] && inargs['o.id.v'] != 'NEW' && inargs['o.id.v'] != 'RESET' && inargs['o.id.v'] != session['agov.eid.verification.id']){ // wrong request, tell fe to stop polling and request a timeout LOG.debug('authentication timeout enforced, due to concurrent requests (verificationRequest missmatch) -> Notify FE & then return a 408') result = """{ "oid4vp": { "status": "TIMEOUT", "verification_url": "${session['agov.eid.verification.link']}", "id": "${idvalue}", "error_code": "REQUEST-MISMATCH", "error_message": "Request Mismatch Detected: Forcing Timeout" }}""" response.setContent(result.toString()) response.setContentType('application/json') response.setHttpStatusCode(200) response.setIsDirectResponse(true) response.setStatus(AuthResponse.AUTH_CONTINUE) return } try { def endPoint = "${parameters.get('eidVerifierBaseUrl')}/api/v1/verifications/${idvalue}" def httpResponse = Http.get() .url(endPoint) .header("Accept", "application/json") .header("traceparent", traceparent) .build() .send(httpClient) // 404 -> request a new verification if(httpResponse.code() == 404){ // Frontend should know that we are starting a new request and not recieve an error def status = "FAILED" // Delete session variable to start a new verification sess.removeAttribute('agov.eid.verification') result = """{ "oid4vp": { "status": "${status}", "verification_url": "", "id": "", "error_code": "HTTP-ERROR", "error_message": "Faild to verify status of verification, http status: ${httpResponse.code()}" }}""" LOG.warn("<== Response: ${httpResponse.code()}") } else if (httpResponse.code() != 200) { LOG.debug("Result: ${httpResponse}") def status = "ERROR" result = """{ "oid4vp": { "status": "${status}", "verification_url": "${session['agov.eid.verification.link']}", "id": "${idvalue}", "error_code": "HTTP-ERROR", "error_message": "failed to verify status of verification ${idvalue}, http status: ${httpResponse.code()}" }}""" LOG.warn("<== Response: ${httpResponse.code()}") } else { def json = new JsonSlurper().parseText(httpResponse.bodyAsString()) LOG.debug(httpResponse.bodyAsString()) if (json.state == 'SUCCESS') { def claims = json.wallet_response.credential_subject_data LOG.debug("Store user data in session") def validFrom = LocalDate.parse(claims.issuance_date, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) def validTo = LocalDate.parse(claims.expiry_date, DateTimeFormatter.ISO_LOCAL_DATE).atTime(23,59,59).atOffset(ZoneOffset.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) sess.setAttribute('agov.eid.User.firstName', claims.given_name) sess.setAttribute('agov.eid.User.lastName', claims.family_name) sess.setAttribute('agov.eid.User.birthDate', claims.birth_date) sess.setAttribute('agov.eid.User.gender', claims.sex) sess.setAttribute('agov.eid.User.svnr', claims.personal_administrative_number.replace('.','')) sess.setAttribute('agov.eid.User.placeOfBirth', claims.birth_place) sess.setAttribute('agov.eid.User.eIdNumber', claims.document_number) // Simpler for later comparison -> Is converted again to upper case in the saml assertion sess.setAttribute('agov.eid.User.nationality', claims.nationality.toString().toLowerCase()) sess.setAttribute('ValidFrom', validFrom) sess.setAttribute('ValidTo', validTo) sess.setAttribute('authenticatedWith', "urn:qa.agov.ch:names:tc:authfactor:eid") sess.setAttribute('idVerification', "Eid") // BUNDBITBK-5203 Dynamic aq levels def requestedRoleLevel = session['agov.requestedRoleLevel'] if(requestedRoleLevel == "600"){ sess.setAttribute('contextClassRefToSet', "urn:qa.agov.ch:names:tc:ac:classes:600") }else{ sess.setAttribute('contextClassRefToSet', "urn:qa.agov.ch:names:tc:ac:classes:500") } // subjectUUID v5 def namespace = UUID.fromString(parameters.get('eidUUIDNamespace')) def uuid = Generators.nameBasedGenerator(namespace).generate(claims.personal_administrative_number) LOG.debug("UUID derived from svnr: ${uuid}") String uuidString = uuid.toString() sess.setAttribute('agov.subjectUUID', '' + uuidString) response.setUserId(uuidString) sess.setAttribute('ch.adnovum.nevisidm.user.extId', uuidString) response.setLoginId(claims.document_number) response.setAuthLevel("EID") result = """{ "oid4vp": { "status": "SUCCEEDED", "verification_url": "${session['agov.eid.verification.link']}", "id": "${idvalue}", "error_code": "NONE" }}""" } else if (json.state == 'FAILED') { LOG .error("Eid verification failed: ${json.wallet_response.error_code} (${json.wallet_response.error_description})") def status = ERROR_CODE_TO_STATUS_MAPPER[json.wallet_response.error_code] ?: 'ERROR' // Send new request & return variables with new id and url if(status == 'FAILED' || status == 'CANCELED'){ // Delete session variable to start a new verification sess.removeAttribute('agov.eid.verification') // Clear variables for for a cleaner result sess.removeAttribute('agov.eid.verification.link') } result = """{ "oid4vp": { "status": "${status}", "verification_url": "${session['agov.eid.verification.link']}", "id": "${idvalue}", "error_code": "${json.wallet_response.error_code}", "error_message": "${json.wallet_response.error_description}" }}""" } else { result = """{ "oid4vp": { "status": "${inargs['o.id.v'] == 'NEW' || inargs['o.id.v'] == 'RESET' ? 'INITIATED' : 'PENDING'}", "verification_url": "${session['agov.eid.verification.link']}", "id": "${idvalue}", "error_code": "NONE" }}""" } } } catch (Exception e) { LOG.error("Eid verification failed: ${e}") result = """{ "oid4vp": { "status": "ERROR", "verification_url": "${session['agov.eid.verification.link']}", "id": "${idvalue}", "error_code": "HTTP-ERROR", "error_message": "failed to verify status of verification ${idvalue}, http exception" }}""" } response.setContent(result.toString()) response.setContentType('application/json') response.setHttpStatusCode(200) response.setIsDirectResponse(true) response.setStatus(AuthResponse.AUTH_CONTINUE) return } // if we reach this place, display GUI LOG.debug("Show GUI") response.setStatus(AuthResponse.AUTH_CONTINUE) return