-
Notifications
You must be signed in to change notification settings - Fork 0
Dev/impl helse #139
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
Open
ivanskodje
wants to merge
23
commits into
main
Choose a base branch
from
dev/impl-helse-id
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Dev/impl helse #139
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
d054f16
feat: added HelseID validation to get NIN in Processor.kt flow valida…
ivanskodje 53a76fd
chore: ktlinth
ivanskodje e184840
refactor: simplified tests, fixed invalid testdata
ivanskodje 9cae9f2
refactor: removed a lot of unused code
ivanskodje bbd1a32
refactor: removed a lot of unused code
ivanskodje b6d048f
refactor: removed a lot of unused code
ivanskodje 1f4e458
refactor: removed a lot of unused code
ivanskodje b809fb3
refactor: removed a lot of unused code
ivanskodje 3d999f2
refactor: removed a lot of unused code
ivanskodje c9f93df
refactor: simplified XPath code, removed unused code, simplified Hels…
ivanskodje 049f171
refactor: simplified namespace context code and made it understandabl…
ivanskodje fa4b84f
refactor: extracted out a common class NinResolver to handle both OCS…
ivanskodje b1da126
refactor: simplified NinResolver
ivanskodje b9fd5d3
chore: ktlint
ivanskodje 8252959
fix: set timezone to Europe/Oslo to ensure we dont get issue with dif…
ivanskodje 090a067
chore: ktlint
ivanskodje 1fdee21
chore: ktlint and hopefully timezone fix
ivanskodje fb6c443
Merge remote-tracking branch 'origin/main' into dev/impl-helse-id
ivanskodje ef75145
Merge branch 'main' into dev/impl-helse-id
RettIProd 6b8db65
Endret accepted scopes
RettIProd 7cd91b7
ktlintformat
RettIProd f97afa8
Tester borked ifm accepted scopes
RettIProd 83890f7
Merge remote-tracking branch 'origin/main' into dev/impl-helse-id
ivanskodje File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
167 changes: 167 additions & 0 deletions
167
ebms-payload/src/main/kotlin/no/nav/emottak/payload/helseid/HelseIdTokenValidator.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
package no.nav.emottak.payload.helseid | ||
|
||
import com.nimbusds.jose.JOSEObjectType | ||
import com.nimbusds.jose.JWSAlgorithm | ||
import com.nimbusds.jwt.JWTClaimNames | ||
import com.nimbusds.jwt.JWTClaimsSet | ||
import com.nimbusds.jwt.JWTParser | ||
import com.nimbusds.jwt.SignedJWT | ||
import no.nav.emottak.payload.helseid.util.XPathEvaluator | ||
import no.nav.emottak.payload.helseid.util.msgHeadNamespaceContext | ||
import no.nav.emottak.utils.environment.getEnvVar | ||
import org.w3c.dom.Document | ||
import java.text.ParseException | ||
import java.time.Instant | ||
import java.time.ZoneId | ||
import java.time.format.DateTimeFormatter | ||
import java.util.Base64 | ||
import java.util.Date | ||
import java.util.Locale | ||
|
||
val HELSE_ID_ISSUER = getEnvVar("HELSE_ID_ISSUER", "https://helseid-sts.test.nhn.no") | ||
|
||
class HelseIdTokenValidator( | ||
private val issuer: String = HELSE_ID_ISSUER, | ||
private val allowedClockSkewInMs: Long = 0 | ||
) { | ||
fun getValidatedNin(base64Token: String, timestamp: Instant): String? = parseSignedJwt(base64Token).also { | ||
validateHeader(it) | ||
validateClaims(it, Date.from(timestamp)) | ||
}.let(::extractNin) | ||
|
||
fun getNin(base64Token: String): String = extractNin(parseSignedJwt(base64Token)) | ||
|
||
fun getNin(signedJwt: SignedJWT): String = extractNin(signedJwt) | ||
|
||
fun getHelseIdTokenFromDocument(document: Document): String? { | ||
val nodes = XPathEvaluator.nodesAt( | ||
document, | ||
msgHeadNamespaceContext, | ||
"/mh:MsgHead/mh:Document/mh:RefDoc[mh:MsgType/@V='A']" + "[mh:MimeType/text()='application/jwt' or mh:MimeType/text()='application/json']" + "/mh:Content/bas:Base64Container" | ||
) | ||
return when (nodes.size) { | ||
0 -> null | ||
1 -> XPathEvaluator.stringAt(nodes[0], msgHeadNamespaceContext, "text()") | ||
else -> error("unable to determine which of the ${nodes.size} attachments that is HelseID-token") | ||
} | ||
} | ||
|
||
private fun parseSignedJwt(base64Token: String): SignedJWT { | ||
val compactToken = if ('.' in base64Token) base64Token else String(Base64.getDecoder().decode(base64Token)) | ||
|
||
val jwt = try { | ||
JWTParser.parse(compactToken) | ||
} catch (ex: ParseException) { | ||
throw RuntimeException("Failed to parse token", ex) | ||
} | ||
|
||
if (jwt !is SignedJWT) error("token is unsigned") | ||
if (jwt.header.algorithm !in SUPPORTED_ALGORITHMS) error("Token is not signed with an approved algorithm") | ||
return jwt | ||
} | ||
|
||
private fun validateHeader(jwt: SignedJWT) { | ||
if (jwt.header.type !in SUPPORTED_JWT_TYPES) error("Unsupported token type ${jwt.header.type}") | ||
} | ||
|
||
private fun validateClaims(jwt: SignedJWT, timestamp: Date) = try { | ||
jwt.jwtClaimsSet | ||
} catch (ex: ParseException) { | ||
throw RuntimeException("Failed to parse claims", ex) | ||
}.also { claims -> | ||
validateTimestamps(claims, timestamp) | ||
if (claims.issuer != issuer) error("Invalid issuer ${claims.issuer}") | ||
validateEssentialClaims(claims) | ||
} | ||
|
||
private fun validateTimestamps(claims: JWTClaimsSet, now: Date) { | ||
issuedAt(claims)?.let { iat -> | ||
if (now.time < iat.time - allowedClockSkewInMs) { | ||
error("${timePrefix(now)} is before issued time ${timePrefix(iat)}") | ||
} | ||
} | ||
claims.expirationTime?.let { exp -> | ||
if (now.time > exp.time + allowedClockSkewInMs) { | ||
error("${timePrefix(now)} is after expiry time ${timePrefix(exp)}") | ||
} | ||
} | ||
claims.notBeforeTime?.let { nbf -> | ||
if (now.time < nbf.time - allowedClockSkewInMs) { | ||
error("${timePrefix(now)} is before not-before time ${timePrefix(nbf)}") | ||
} | ||
} | ||
authTime(claims)?.let { at -> | ||
if (now.time < at.time - allowedClockSkewInMs) { | ||
error("${timePrefix(now)} is before auth-time ${timePrefix(at)}") | ||
} | ||
} | ||
} | ||
|
||
private fun validateEssentialClaims(claims: JWTClaimsSet) { | ||
if (claims.getClaim(SECURITY_LEVEL_CLAIM) != SECURITY_LEVEL) { | ||
error("Invalid security-level") | ||
} | ||
if (claims.audience.none { it in SUPPORTED_AUDIENCE }) { | ||
error("Token does not contain required audience") | ||
} | ||
if (getStringArray(claims, "scope").none { it in SUPPORTED_SCOPES }) { | ||
error("Token does not contain required scope") | ||
} | ||
} | ||
|
||
private fun extractNin(jwt: SignedJWT): String = getString(jwt.jwtClaimsSet, PID_CLAIM) | ||
|
||
private fun issuedAt(claims: JWTClaimsSet): Date? = | ||
(claims.getClaim(JWTClaimNames.ISSUED_AT) as? Number)?.let { Date(it.toLong() * 1000) } | ||
|
||
private fun authTime(claims: JWTClaimsSet): Date? = | ||
(claims.getClaim("auth_time") as? Number)?.let { Date(it.toLong()) } | ||
|
||
private fun getString(claims: JWTClaimsSet, name: String): String = try { | ||
claims.getStringClaim(name) | ||
} catch (ex: ParseException) { | ||
throw RuntimeException("failed to read claim '$name'", ex) | ||
} | ||
|
||
private fun getStringArray(claims: JWTClaimsSet, name: String): Array<String> = try { | ||
claims.getStringArrayClaim(name) | ||
} catch (ex: ParseException) { | ||
throw RuntimeException("failed to read claim '$name'", ex) | ||
} | ||
|
||
private fun timePrefix(date: Date): String = DATE_FMT.format(date.toInstant()) | ||
|
||
companion object { | ||
private val OSLO_ZONE: ZoneId = ZoneId.of("Europe/Oslo") | ||
private val DATE_FMT: DateTimeFormatter = | ||
DateTimeFormatter | ||
.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.US) | ||
.withZone(OSLO_ZONE) | ||
private val SUPPORTED_ALGORITHMS = listOf( | ||
JWSAlgorithm.RS256, | ||
JWSAlgorithm.RS384, | ||
JWSAlgorithm.RS512, | ||
JWSAlgorithm.PS256, | ||
JWSAlgorithm.PS384, | ||
JWSAlgorithm.PS512, | ||
JWSAlgorithm.ES256, | ||
JWSAlgorithm.ES384, | ||
JWSAlgorithm.ES512 | ||
) | ||
internal val SUPPORTED_AUDIENCE = listOf( | ||
"nav:sign-message" | ||
) | ||
internal val SUPPORTED_SCOPES = listOf( | ||
"nav:sign-message/msghead" | ||
) | ||
private val SUPPORTED_JWT_TYPES = listOf( | ||
JOSEObjectType.JWT, | ||
JOSEObjectType("at+jwt"), | ||
JOSEObjectType("application/at+jwt") | ||
) | ||
|
||
private const val SECURITY_LEVEL = "4" | ||
private const val SECURITY_LEVEL_CLAIM = "helseid://claims/identity/security_level" | ||
private const val PID_CLAIM = "helseid://claims/identity/pid" | ||
} | ||
} |
45 changes: 45 additions & 0 deletions
45
ebms-payload/src/main/kotlin/no/nav/emottak/payload/helseid/NinResolver.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
package no.nav.emottak.payload.helseid | ||
|
||
import no.nav.emottak.crypto.KeyStoreManager | ||
import no.nav.emottak.payload.crypto.payloadSigneringConfig | ||
import no.nav.emottak.payload.defaultHttpClient | ||
import no.nav.emottak.payload.helseid.util.msgHeadNamespaceContext | ||
import no.nav.emottak.payload.ocspstatus.OcspStatusService | ||
import org.slf4j.LoggerFactory | ||
import org.w3c.dom.Document | ||
import java.security.cert.X509Certificate | ||
import java.time.Instant | ||
import java.time.OffsetDateTime | ||
import java.time.format.DateTimeFormatter | ||
|
||
class NinResolver( | ||
private val tokenValidator: HelseIdTokenValidator = HelseIdTokenValidator(), | ||
private val ocspStatusService: OcspStatusService = OcspStatusService( | ||
defaultHttpClient().invoke(), | ||
KeyStoreManager(payloadSigneringConfig()) | ||
) | ||
) { | ||
private val log = LoggerFactory.getLogger(NinResolver::class.java) | ||
|
||
suspend fun resolve(document: Document, certificate: X509Certificate): String? { | ||
val token = tokenValidator.getHelseIdTokenFromDocument(document) | ||
|
||
val nin = token?.let { | ||
runCatching { | ||
tokenValidator.getValidatedNin(it, parseDateOrThrow(extractGeneratedDate(document))) | ||
}.onFailure { log.error("HelseID validation failed", it) }.getOrNull() | ||
} | ||
|
||
return nin ?: ocspStatusService.getOCSPStatus(certificate).fnr | ||
} | ||
|
||
private fun extractGeneratedDate(document: Document): String? { | ||
val ns = msgHeadNamespaceContext.getNamespaceURI("mh") ?: return null | ||
return document.getElementsByTagNameNS(ns, "GenDate").item(0)?.textContent | ||
} | ||
|
||
private fun parseDateOrThrow(date: String?): Instant { | ||
requireNotNull(date) { "GenDate missing or empty in document" } | ||
return OffsetDateTime.parse(date, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant() | ||
} | ||
} |
25 changes: 25 additions & 0 deletions
25
ebms-payload/src/main/kotlin/no/nav/emottak/payload/helseid/util/MsgHeadNamespaceContext.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package no.nav.emottak.payload.helseid.util | ||
|
||
import org.apache.xml.security.utils.Constants | ||
import javax.xml.XMLConstants | ||
import javax.xml.namespace.NamespaceContext | ||
|
||
val msgHeadNamespaceContext: NamespaceContext = object : NamespaceContext { | ||
|
||
private val prefixToUri = mapOf( | ||
"dsig" to Constants.SignatureSpecNS, | ||
"xades" to "http://uri.etsi.org/01903/v1.3.2#", | ||
"dss" to "urn:oasis:names:tc:dss:1.0:core:schema", | ||
"mh" to "http://www.kith.no/xmlstds/msghead/2006-05-24", | ||
"bas" to "http://www.kith.no/xmlstds/base64container" | ||
) | ||
|
||
override fun getNamespaceURI(prefix: String?): String = | ||
prefixToUri[prefix] ?: XMLConstants.NULL_NS_URI | ||
|
||
override fun getPrefix(namespaceUri: String?): String? = | ||
prefixToUri.entries.firstOrNull { it.value == namespaceUri }?.key | ||
|
||
override fun getPrefixes(namespaceUri: String?): Iterator<String> = | ||
prefixToUri.filterValues { it == namespaceUri }.keys.iterator() | ||
} |
45 changes: 45 additions & 0 deletions
45
ebms-payload/src/main/kotlin/no/nav/emottak/payload/helseid/util/XPathEvaluator.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
package no.nav.emottak.payload.helseid.util | ||
|
||
import org.w3c.dom.Node | ||
import org.w3c.dom.NodeList | ||
import javax.xml.namespace.NamespaceContext | ||
import javax.xml.namespace.QName | ||
import javax.xml.xpath.XPathConstants | ||
import javax.xml.xpath.XPathExpressionException | ||
import javax.xml.xpath.XPathFactory | ||
|
||
object XPathEvaluator { | ||
|
||
private val xPathFactory: XPathFactory = XPathFactory.newInstance() | ||
|
||
fun stringAt(node: Node, namespaceContext: NamespaceContext?, xPath: String): String? = | ||
(evaluate(node, namespaceContext, xPath, XPathConstants.STRING) as String?) | ||
?.trim() | ||
?.replace(MULTIPLE_WHITESPACE_REGEX, " ") | ||
?.ifBlank { null } | ||
|
||
fun nodesAt(node: Node, namespaceContext: NamespaceContext?, xPath: String): List<Node> { | ||
val nodeList = evaluate(node, namespaceContext, xPath, XPathConstants.NODESET) as NodeList | ||
val nodes = ArrayList<Node>(nodeList.length) | ||
for (index in 0 until nodeList.length) nodes.add(nodeList.item(index)) | ||
return nodes | ||
} | ||
|
||
private fun evaluate( | ||
contextNode: Node, | ||
namespaceContext: NamespaceContext?, | ||
xPathExpression: String, | ||
expectedResultType: QName | ||
) = try { | ||
val xPath = xPathFactory.newXPath() | ||
if (namespaceContext != null) { | ||
xPath.namespaceContext = namespaceContext | ||
} | ||
val compiledExpression = xPath.compile(xPathExpression) | ||
compiledExpression.evaluate(contextNode, expectedResultType) | ||
} catch (ex: XPathExpressionException) { | ||
throw RuntimeException("Invalid XPath expression", ex) | ||
} | ||
|
||
private val MULTIPLE_WHITESPACE_REGEX = Regex("\\s+") | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variabel duplisering, men kanskje ikke vits å være så pirkete på kode vi har cop-pasted inn. 😅
Bare noterer observasjoner. Mer valgfritt om vi gjør noe med dem.