diff --git a/.github/workflows/azure.yaml b/.github/workflows/azure.yaml index 37eb862..b6a8d78 100644 --- a/.github/workflows/azure.yaml +++ b/.github/workflows/azure.yaml @@ -16,18 +16,12 @@ jobs: - name: Checkout latest code uses: actions/checkout@v2 - - name: Set up JDK 16 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v2 with: - java-version: 16 - - - name: Setup build cache - uses: actions/cache@v2 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }} - restore-keys: | - ${{ runner.os }}-gradle- + java-version: '17' + distribution: 'temurin' + cache: 'gradle' - name: Setup Gradle wrapper cache uses: actions/cache@v2 @@ -37,8 +31,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle-wrapper- + - name: Verify Gradle wrapper checksum + uses: gradle/wrapper-validation-action@v1 + - name: Build with Gradle - run: ./gradlew clean azure:build azure:shadowJar + run: ./gradlew clean wonderwalled-azure:build wonderwalled-azure:shadowJar - name: Login to GitHub Docker Registry uses: docker/login-action@v1 @@ -48,12 +45,12 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push the Docker image - working-directory: ./azure + working-directory: ./wonderwalled-azure run: | docker build --pull --tag ${IMAGE} . docker push ${IMAGE} - deploy-dev-gcp: + deploy: name: Deploy to dev-gcp needs: build runs-on: ubuntu-latest @@ -64,17 +61,3 @@ jobs: APIKEY: ${{ secrets.NAIS_DEPLOY_APIKEY }} CLUSTER: dev-gcp RESOURCE: .nais/azure.yaml - VARS: .nais/dev/azure.yaml - - deploy-prod-gcp: - name: Deploy to prod-gcp - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: nais/deploy/actions/deploy@v1 - env: - APIKEY: ${{ secrets.NAIS_DEPLOY_APIKEY }} - CLUSTER: prod-gcp - RESOURCE: .nais/azure.yaml - VARS: .nais/prod/azure.yaml diff --git a/.github/workflows/idporten.yaml b/.github/workflows/idporten.yaml index 585f402..a730bad 100644 --- a/.github/workflows/idporten.yaml +++ b/.github/workflows/idporten.yaml @@ -16,18 +16,12 @@ jobs: - name: Checkout latest code uses: actions/checkout@v2 - - name: Set up JDK 16 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v2 with: - java-version: 16 - - - name: Setup build cache - uses: actions/cache@v2 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }} - restore-keys: | - ${{ runner.os }}-gradle- + java-version: '17' + distribution: 'temurin' + cache: 'gradle' - name: Setup Gradle wrapper cache uses: actions/cache@v2 @@ -37,8 +31,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle-wrapper- + - name: Verify Gradle wrapper checksum + uses: gradle/wrapper-validation-action@v1 + - name: Build with Gradle - run: ./gradlew clean idporten:build idporten:shadowJar + run: ./gradlew clean wonderwalled-idporten:build wonderwalled-idporten:shadowJar - name: Login to GitHub Docker Registry uses: docker/login-action@v1 @@ -48,12 +45,12 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push the Docker image - working-directory: ./idporten + working-directory: ./wonderwalled-idporten run: | docker build --pull --tag ${IMAGE} . docker push ${IMAGE} - deploy-dev: + deploy: name: Deploy to dev-gcp needs: build runs-on: ubuntu-latest @@ -64,17 +61,3 @@ jobs: APIKEY: ${{ secrets.NAIS_DEPLOY_APIKEY }} CLUSTER: dev-gcp RESOURCE: .nais/idporten.yaml - VARS: .nais/dev/idporten.yaml - - deploy-prod: - name: Deploy to prod-gcp - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: nais/deploy/actions/deploy@v1 - env: - APIKEY: ${{ secrets.NAIS_DEPLOY_APIKEY }} - CLUSTER: prod-gcp - RESOURCE: .nais/idporten.yaml - VARS: .nais/prod/idporten.yaml diff --git a/.nais/azure.yaml b/.nais/azure.yaml index 2d8cc5a..069257e 100644 --- a/.nais/azure.yaml +++ b/.nais/azure.yaml @@ -31,7 +31,7 @@ spec: cpu: "50m" memory: "256Mi" ingresses: - - {{ ingress }} + - https://wonderwalled.dev.intern.nav.no azure: application: enabled: true diff --git a/.nais/dev/azure.yaml b/.nais/dev/azure.yaml deleted file mode 100644 index 9b07bd1..0000000 --- a/.nais/dev/azure.yaml +++ /dev/null @@ -1 +0,0 @@ -ingress: "https://wonderwalled-azure.dev.intern.nav.no" \ No newline at end of file diff --git a/.nais/dev/idporten.yaml b/.nais/dev/idporten.yaml deleted file mode 100644 index faa6e0b..0000000 --- a/.nais/dev/idporten.yaml +++ /dev/null @@ -1 +0,0 @@ -ingress: "https://wonderwalled-idporten.dev.intern.nav.no" \ No newline at end of file diff --git a/.nais/idporten.yaml b/.nais/idporten.yaml index fed8837..cf3b912 100644 --- a/.nais/idporten.yaml +++ b/.nais/idporten.yaml @@ -31,7 +31,7 @@ spec: cpu: "50m" memory: "256Mi" ingresses: - - {{ ingress }} + - https://wonderwalled.dev.nav.no idporten: enabled: true sidecar: diff --git a/.nais/prod/azure.yaml b/.nais/prod/azure.yaml deleted file mode 100644 index 15e02d7..0000000 --- a/.nais/prod/azure.yaml +++ /dev/null @@ -1 +0,0 @@ -ingress: "https://wonderwalled-azure.intern.nav.no" \ No newline at end of file diff --git a/.nais/prod/idporten.yaml b/.nais/prod/idporten.yaml deleted file mode 100644 index 5848c9e..0000000 --- a/.nais/prod/idporten.yaml +++ /dev/null @@ -1 +0,0 @@ -ingress: "https://wonderwalled-idporten.intern.nav.no" \ No newline at end of file diff --git a/azure/src/main/kotlin/io/nais/Wonderwalled.kt b/azure/src/main/kotlin/io/nais/Wonderwalled.kt deleted file mode 100644 index d854c6f..0000000 --- a/azure/src/main/kotlin/io/nais/Wonderwalled.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.nais - -import com.auth0.jwk.JwkProviderBuilder -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.SerializationFeature -import io.ktor.application.ApplicationCall -import io.ktor.application.call -import io.ktor.application.install -import io.ktor.auth.authenticate -import io.ktor.auth.authentication -import io.ktor.auth.jwt.JWTPrincipal -import io.ktor.auth.jwt.jwt -import io.ktor.client.HttpClient -import io.ktor.client.engine.apache.Apache -import io.ktor.client.features.json.JacksonSerializer -import io.ktor.client.features.json.JsonFeature -import io.ktor.features.CallLogging -import io.ktor.features.ContentNegotiation -import io.ktor.http.HttpStatusCode -import io.ktor.jackson.jackson -import io.ktor.request.host -import io.ktor.request.path -import io.ktor.response.respond -import io.ktor.response.respondRedirect -import io.ktor.routing.IgnoreTrailingSlash -import io.ktor.routing.get -import io.ktor.routing.route -import io.ktor.routing.routing -import io.ktor.server.engine.embeddedServer -import io.ktor.server.netty.Netty -import org.slf4j.event.Level -import java.net.URL -import java.util.concurrent.TimeUnit - -internal val httpClient = HttpClient(Apache) { - install(JsonFeature) { - serializer = JacksonSerializer { - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - } - } -} - -fun main() { - val config = Configuration() - val jwkProvider = JwkProviderBuilder(URL(config.azure.openIdConfiguration.jwksUri)) - .cached(10, 1, TimeUnit.HOURS) - .rateLimited(10, 1, TimeUnit.MINUTES) - .build() - - embeddedServer(Netty, port = config.port) { - install(ContentNegotiation) { - jackson { - enable(SerializationFeature.INDENT_OUTPUT) - } - } - install(IgnoreTrailingSlash) - install(CallLogging) { - level = Level.INFO - } - - authentication { - jwt { - verifier(jwkProvider, config.azure.openIdConfiguration.issuer) { - withAudience(config.azure.clientId) - } - - validate { credentials -> JWTPrincipal(credentials.payload) } - - // challenge is called if the request authentication fails or is not provided - challenge { _, _ -> - val ingress = config.ingress.ifEmpty(defaultValue = { - "${call.request.local.scheme}://${call.request.host()}" - }) - - // redirect to login endpoint (wonderwall) and indicate that the user should be redirected back - // to the original request path after authentication - call.respondRedirect("$ingress/oauth2/login?redirect=${call.request.path()}") - } - } - } - - routing { - route("internal") { - get("is_alive") { - call.respond("alive") - } - get("is_ready") { - call.respond("ready") - } - } - authenticate { - route("api") { - get("headers") { - call.respond(call.requestHeaders()) - } - - get("me") { - when (val tokenInfo = call.getTokenInfo()) { - null -> call.respond(HttpStatusCode.Unauthorized, "Could not find a valid principal") - else -> call.respond(tokenInfo) - } - } - } - } - } - }.start(wait = true) -} - -private fun ApplicationCall.getTokenInfo(): Map? = authentication - .principal() - ?.let { principal -> - principal.payload.claims.entries.associate { claim -> - claim.key to claim.value.`as`(JsonNode::class.java) - } - } - -private fun ApplicationCall.requestHeaders(): Map = request.headers.entries() - .associate { header -> header.key to header.value.joinToString() } diff --git a/build.gradle.kts b/build.gradle.kts index 771114a..06a85e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,16 +1,16 @@ object Versions { const val konfig = "1.6.10.0" const val kotlinlogging = "2.0.10" - const val ktor = "1.6.4" - const val logstash = "6.6" - const val logback = "1.2.3" + const val ktor = "1.6.7" + const val logstash = "7.0.1" + const val logback = "1.2.9" } plugins { - kotlin("jvm") version "1.5.21" - id("org.jmailen.kotlinter") version "3.6.0" + kotlin("jvm") version "1.6.10" + id("org.jmailen.kotlinter") version "3.7.0" id("com.github.ben-manes.versions") version "0.39.0" - id("com.github.johnrengelman.shadow") version "7.1.0" apply false + id("com.github.johnrengelman.shadow") version "7.1.1" apply false } allprojects { @@ -28,8 +28,8 @@ subprojects { apply(plugin = "com.github.johnrengelman.shadow") java { - sourceCompatibility = JavaVersion.VERSION_16 - targetCompatibility = JavaVersion.VERSION_16 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } tasks { @@ -38,7 +38,7 @@ subprojects { } withType { kotlinOptions { - jvmTarget = "16" + jvmTarget = "17" } } withType { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..d2880ba 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/idporten/src/main/kotlin/io/nais/Wonderwalled.kt b/idporten/src/main/kotlin/io/nais/Wonderwalled.kt deleted file mode 100644 index 74c402c..0000000 --- a/idporten/src/main/kotlin/io/nais/Wonderwalled.kt +++ /dev/null @@ -1,121 +0,0 @@ -package io.nais - -import com.auth0.jwk.JwkProviderBuilder -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.SerializationFeature -import io.ktor.application.ApplicationCall -import io.ktor.application.call -import io.ktor.application.install -import io.ktor.auth.authenticate -import io.ktor.auth.authentication -import io.ktor.auth.jwt.JWTPrincipal -import io.ktor.auth.jwt.jwt -import io.ktor.client.HttpClient -import io.ktor.client.engine.apache.Apache -import io.ktor.client.features.json.JacksonSerializer -import io.ktor.client.features.json.JsonFeature -import io.ktor.features.CallLogging -import io.ktor.features.ContentNegotiation -import io.ktor.http.HttpStatusCode -import io.ktor.jackson.jackson -import io.ktor.request.host -import io.ktor.request.path -import io.ktor.response.respond -import io.ktor.response.respondRedirect -import io.ktor.routing.IgnoreTrailingSlash -import io.ktor.routing.get -import io.ktor.routing.route -import io.ktor.routing.routing -import io.ktor.server.engine.embeddedServer -import io.ktor.server.netty.Netty -import org.slf4j.event.Level -import java.net.URL -import java.util.concurrent.TimeUnit - -internal val httpClient = HttpClient(Apache) { - install(JsonFeature) { - serializer = JacksonSerializer { - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - } - } -} - -fun main() { - val config = Configuration() - val jwkProvider = JwkProviderBuilder(URL(config.idporten.openIdConfiguration.jwksUri)) - .cached(10, 1, TimeUnit.HOURS) - .rateLimited(10, 1, TimeUnit.MINUTES) - .build() - - embeddedServer(Netty, port = config.port) { - install(ContentNegotiation) { - jackson { - enable(SerializationFeature.INDENT_OUTPUT) - } - } - install(IgnoreTrailingSlash) - install(CallLogging) { - level = Level.INFO - } - - authentication { - jwt { - verifier(jwkProvider, config.idporten.openIdConfiguration.issuer) { - withClaimPresence("client_id") - withClaim("client_id", config.idporten.clientId) - } - - validate { credentials -> JWTPrincipal(credentials.payload) } - - // challenge is called if the request authentication fails or is not provided - challenge { _, _ -> - val ingress = config.ingress.ifEmpty(defaultValue = { - "${call.request.local.scheme}://${call.request.host()}" - }) - - // redirect to login endpoint (wonderwall) and indicate that the user should be redirected back - // to the original request path after authentication - call.respondRedirect("$ingress/oauth2/login?redirect=${call.request.path()}") - } - } - } - - routing { - route("internal") { - get("is_alive") { - call.respond("alive") - } - get("is_ready") { - call.respond("ready") - } - } - authenticate { - route("api") { - get("headers") { - call.respond(call.requestHeaders()) - } - - get("me") { - when (val tokenInfo = call.getTokenInfo()) { - null -> call.respond(HttpStatusCode.Unauthorized, "Could not find a valid principal") - else -> call.respond(tokenInfo) - } - } - } - } - } - }.start(wait = true) -} - - -private fun ApplicationCall.getTokenInfo(): Map? = authentication - .principal() - ?.let { principal -> - principal.payload.claims.entries.associate { claim -> - claim.key to claim.value.`as`(JsonNode::class.java) - } - } - -private fun ApplicationCall.requestHeaders(): Map = request.headers.entries() - .associate { header -> header.key to header.value.joinToString() } diff --git a/settings.gradle b/settings.gradle index 48a55b1..8701730 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ rootProject.name = "wonderwalled" -include("azure", "idporten") +include("wonderwalled-common", "wonderwalled-azure", "wonderwalled-idporten") diff --git a/azure/Dockerfile b/wonderwalled-azure/Dockerfile similarity index 82% rename from azure/Dockerfile rename to wonderwalled-azure/Dockerfile index 8ad6daa..576c05e 100644 --- a/azure/Dockerfile +++ b/wonderwalled-azure/Dockerfile @@ -1,3 +1,3 @@ -FROM navikt/java:16 +FROM navikt/java:17 ENV JAVA_OPTS="-Dlogback.configurationFile=logback-remote.xml" COPY build/libs/*-all.jar app.jar diff --git a/wonderwalled-azure/build.gradle.kts b/wonderwalled-azure/build.gradle.kts new file mode 100644 index 0000000..2de2029 --- /dev/null +++ b/wonderwalled-azure/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(project(":wonderwalled-common")) +} diff --git a/azure/src/main/kotlin/io/nais/Configuration.kt b/wonderwalled-azure/src/main/kotlin/io/nais/Configuration.kt similarity index 62% rename from azure/src/main/kotlin/io/nais/Configuration.kt rename to wonderwalled-azure/src/main/kotlin/io/nais/Configuration.kt index d03df13..9ac004b 100644 --- a/azure/src/main/kotlin/io/nais/Configuration.kt +++ b/wonderwalled-azure/src/main/kotlin/io/nais/Configuration.kt @@ -1,14 +1,14 @@ package io.nais -import com.fasterxml.jackson.annotation.JsonProperty import com.natpryce.konfig.ConfigurationProperties.Companion.systemProperties import com.natpryce.konfig.EnvironmentVariables import com.natpryce.konfig.Key import com.natpryce.konfig.intType import com.natpryce.konfig.overriding import com.natpryce.konfig.stringType -import io.ktor.client.request.get -import kotlinx.coroutines.runBlocking +import io.nais.common.OpenIdConfiguration +import io.nais.common.defaultHttpClient +import io.nais.common.getOpenIdConfiguration private val config = systemProperties() overriding EnvironmentVariables() @@ -26,19 +26,6 @@ data class Configuration( data class Azure( val clientId: String = config[Key("azure.app.client.id", stringType)], val wellKnownConfigurationUrl: String = config[Key("azure.app.well.known.url", stringType)], - val openIdConfiguration: OpenIdConfiguration = runBlocking { - httpClient.get(wellKnownConfigurationUrl) - } + val openIdConfiguration: OpenIdConfiguration = defaultHttpClient().getOpenIdConfiguration(wellKnownConfigurationUrl) ) } - -data class OpenIdConfiguration( - @JsonProperty("jwks_uri") - val jwksUri: String, - @JsonProperty("issuer") - val issuer: String, - @JsonProperty("token_endpoint") - val tokenEndpoint: String, - @JsonProperty("authorization_endpoint") - val authorizationEndpoint: String -) diff --git a/wonderwalled-azure/src/main/kotlin/io/nais/Wonderwalled.kt b/wonderwalled-azure/src/main/kotlin/io/nais/Wonderwalled.kt new file mode 100644 index 0000000..67953a5 --- /dev/null +++ b/wonderwalled-azure/src/main/kotlin/io/nais/Wonderwalled.kt @@ -0,0 +1,79 @@ +package io.nais + +import com.auth0.jwk.JwkProviderBuilder +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.auth.authenticate +import io.ktor.auth.authentication +import io.ktor.auth.jwt.JWTPrincipal +import io.ktor.auth.jwt.jwt +import io.ktor.http.HttpStatusCode +import io.ktor.request.host +import io.ktor.request.path +import io.ktor.response.respond +import io.ktor.response.respondRedirect +import io.ktor.routing.get +import io.ktor.routing.route +import io.ktor.routing.routing +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.nais.common.commonSetup +import io.nais.common.getTokenInfo +import io.nais.common.requestHeaders +import java.net.URL +import java.util.concurrent.TimeUnit + +fun main() { + val config = Configuration() + + embeddedServer(Netty, port = config.port) { + wonderwalled(config) + }.start(wait = true) +} + +fun Application.wonderwalled(config: Configuration) { + val jwkProvider = JwkProviderBuilder(URL(config.azure.openIdConfiguration.jwksUri)) + .cached(10, 1, TimeUnit.HOURS) + .rateLimited(10, 1, TimeUnit.MINUTES) + .build() + + commonSetup() + + authentication { + jwt { + verifier(jwkProvider, config.azure.openIdConfiguration.issuer) { + withAudience(config.azure.clientId) + } + + validate { credentials -> JWTPrincipal(credentials.payload) } + + // challenge is called if the request authentication fails or is not provided + challenge { _, _ -> + val ingress = config.ingress.ifEmpty(defaultValue = { + "${call.request.local.scheme}://${call.request.host()}" + }) + + // redirect to login endpoint (wonderwall) and indicate that the user should be redirected back + // to the original request path after authentication + call.respondRedirect("$ingress/oauth2/login?redirect=${call.request.path()}") + } + } + } + + routing { + authenticate { + route("api") { + get("headers") { + call.respond(call.requestHeaders()) + } + + get("me") { + when (val tokenInfo = call.getTokenInfo()) { + null -> call.respond(HttpStatusCode.Unauthorized, "Could not find a valid principal") + else -> call.respond(tokenInfo) + } + } + } + } + } +} diff --git a/azure/src/main/resources/logback-remote.xml b/wonderwalled-azure/src/main/resources/logback-remote.xml similarity index 100% rename from azure/src/main/resources/logback-remote.xml rename to wonderwalled-azure/src/main/resources/logback-remote.xml diff --git a/azure/src/main/resources/logback.xml b/wonderwalled-azure/src/main/resources/logback.xml similarity index 100% rename from azure/src/main/resources/logback.xml rename to wonderwalled-azure/src/main/resources/logback.xml diff --git a/wonderwalled-common/src/main/kotlin/io/nais/common/Application.kt b/wonderwalled-common/src/main/kotlin/io/nais/common/Application.kt new file mode 100644 index 0000000..9f28167 --- /dev/null +++ b/wonderwalled-common/src/main/kotlin/io/nais/common/Application.kt @@ -0,0 +1,60 @@ +package io.nais.common + +import com.fasterxml.jackson.databind.SerializationFeature +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.application.install +import io.ktor.features.CallLogging +import io.ktor.features.ContentNegotiation +import io.ktor.jackson.jackson +import io.ktor.request.path +import io.ktor.response.respond +import io.ktor.response.respondRedirect +import io.ktor.routing.IgnoreTrailingSlash +import io.ktor.routing.Routing +import io.ktor.routing.get +import io.ktor.routing.route +import io.ktor.routing.routing +import org.slf4j.event.Level + +fun Application.commonSetup() { + installFeatures() + + routing { + contextRoot() + health() + } +} + +fun Application.installFeatures() { + install(ContentNegotiation) { + jackson { + enable(SerializationFeature.INDENT_OUTPUT) + } + } + + install(IgnoreTrailingSlash) + + install(CallLogging) { + level = Level.INFO + disableDefaultColors() + filter { call -> !call.request.path().startsWith("/internal") } + } +} + +fun Routing.health() { + route("internal") { + get("is_alive") { + call.respond("alive") + } + get("is_ready") { + call.respond("ready") + } + } +} + +fun Routing.contextRoot() { + get("/") { + call.respondRedirect("/api/me") + } +} diff --git a/wonderwalled-common/src/main/kotlin/io/nais/common/ApplicationCall.kt b/wonderwalled-common/src/main/kotlin/io/nais/common/ApplicationCall.kt new file mode 100644 index 0000000..906e145 --- /dev/null +++ b/wonderwalled-common/src/main/kotlin/io/nais/common/ApplicationCall.kt @@ -0,0 +1,17 @@ +package io.nais.common + +import com.fasterxml.jackson.databind.JsonNode +import io.ktor.application.ApplicationCall +import io.ktor.auth.authentication +import io.ktor.auth.jwt.JWTPrincipal + +fun ApplicationCall.getTokenInfo(): Map? = authentication + .principal() + ?.let { principal -> + principal.payload.claims.entries.associate { claim -> + claim.key to claim.value.`as`(JsonNode::class.java) + } + } + +fun ApplicationCall.requestHeaders(): Map = request.headers.entries() + .associate { header -> header.key to header.value.joinToString() } diff --git a/wonderwalled-common/src/main/kotlin/io/nais/common/HttpClient.kt b/wonderwalled-common/src/main/kotlin/io/nais/common/HttpClient.kt new file mode 100644 index 0000000..1b8e779 --- /dev/null +++ b/wonderwalled-common/src/main/kotlin/io/nais/common/HttpClient.kt @@ -0,0 +1,21 @@ +package io.nais.common + +import com.fasterxml.jackson.databind.DeserializationFeature +import io.ktor.client.HttpClient +import io.ktor.client.engine.apache.Apache +import io.ktor.client.features.json.JacksonSerializer +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.request.get +import kotlinx.coroutines.runBlocking + +fun defaultHttpClient() = HttpClient(Apache) { + install(JsonFeature) { + serializer = JacksonSerializer { + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } + } +} + +fun HttpClient.getOpenIdConfiguration(url: String): OpenIdConfiguration = runBlocking { + get(url).also { it.validate(url) } +} diff --git a/wonderwalled-common/src/main/kotlin/io/nais/common/OpenId.kt b/wonderwalled-common/src/main/kotlin/io/nais/common/OpenId.kt new file mode 100644 index 0000000..3874857 --- /dev/null +++ b/wonderwalled-common/src/main/kotlin/io/nais/common/OpenId.kt @@ -0,0 +1,29 @@ +package io.nais.common + +import com.fasterxml.jackson.annotation.JsonProperty + +const val WELL_KNOWN_PATH = "/.well-known" +const val OPENID_CONFIGURATION_PATH = "/openid-configuration" +const val WELL_KNOWN_OPENID_CONFIGURATION_PATH = WELL_KNOWN_PATH + OPENID_CONFIGURATION_PATH + +data class OpenIdConfiguration( + @JsonProperty("jwks_uri") + val jwksUri: String, + @JsonProperty("issuer") + val issuer: String, + @JsonProperty("token_endpoint") + val tokenEndpoint: String, + @JsonProperty("authorization_endpoint") + val authorizationEndpoint: String +) { + fun validate(wellKnownConfigurationUrl: String) { + val resolvedAuthority: String = this.issuer.removeSuffix("/") + WELL_KNOWN_OPENID_CONFIGURATION_PATH + if (resolvedAuthority != wellKnownConfigurationUrl) { + throw invalidOpenIdConfigurationException(wellKnownConfigurationUrl, resolvedAuthority) + } + } +} + +private fun invalidOpenIdConfigurationException(expected: String, got: String): RuntimeException { + return RuntimeException("authority does not match the issuer returned by provider: expected $expected, got $got") +} diff --git a/idporten/src/main/resources/logback-remote.xml b/wonderwalled-common/src/main/resources/logback-remote.xml similarity index 100% rename from idporten/src/main/resources/logback-remote.xml rename to wonderwalled-common/src/main/resources/logback-remote.xml diff --git a/idporten/src/main/resources/logback.xml b/wonderwalled-common/src/main/resources/logback.xml similarity index 100% rename from idporten/src/main/resources/logback.xml rename to wonderwalled-common/src/main/resources/logback.xml diff --git a/idporten/Dockerfile b/wonderwalled-idporten/Dockerfile similarity index 82% rename from idporten/Dockerfile rename to wonderwalled-idporten/Dockerfile index 8ad6daa..576c05e 100644 --- a/idporten/Dockerfile +++ b/wonderwalled-idporten/Dockerfile @@ -1,3 +1,3 @@ -FROM navikt/java:16 +FROM navikt/java:17 ENV JAVA_OPTS="-Dlogback.configurationFile=logback-remote.xml" COPY build/libs/*-all.jar app.jar diff --git a/wonderwalled-idporten/build.gradle.kts b/wonderwalled-idporten/build.gradle.kts new file mode 100644 index 0000000..2de2029 --- /dev/null +++ b/wonderwalled-idporten/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(project(":wonderwalled-common")) +} diff --git a/idporten/src/main/kotlin/io/nais/Configuration.kt b/wonderwalled-idporten/src/main/kotlin/io/nais/Configuration.kt similarity index 62% rename from idporten/src/main/kotlin/io/nais/Configuration.kt rename to wonderwalled-idporten/src/main/kotlin/io/nais/Configuration.kt index 3307294..435212a 100644 --- a/idporten/src/main/kotlin/io/nais/Configuration.kt +++ b/wonderwalled-idporten/src/main/kotlin/io/nais/Configuration.kt @@ -1,14 +1,14 @@ package io.nais -import com.fasterxml.jackson.annotation.JsonProperty import com.natpryce.konfig.ConfigurationProperties.Companion.systemProperties import com.natpryce.konfig.EnvironmentVariables import com.natpryce.konfig.Key import com.natpryce.konfig.intType import com.natpryce.konfig.overriding import com.natpryce.konfig.stringType -import io.ktor.client.request.get -import kotlinx.coroutines.runBlocking +import io.nais.common.OpenIdConfiguration +import io.nais.common.defaultHttpClient +import io.nais.common.getOpenIdConfiguration private val config = systemProperties() overriding EnvironmentVariables() @@ -26,19 +26,6 @@ data class Configuration( data class IdPorten( val clientId: String = config[Key("idporten.client.id", stringType)], val wellKnownConfigurationUrl: String = config[Key("idporten.well.known.url", stringType)], - val openIdConfiguration: OpenIdConfiguration = runBlocking { - httpClient.get(wellKnownConfigurationUrl) - } + val openIdConfiguration: OpenIdConfiguration = defaultHttpClient().getOpenIdConfiguration(wellKnownConfigurationUrl) ) } - -data class OpenIdConfiguration( - @JsonProperty("jwks_uri") - val jwksUri: String, - @JsonProperty("issuer") - val issuer: String, - @JsonProperty("token_endpoint") - val tokenEndpoint: String, - @JsonProperty("authorization_endpoint") - val authorizationEndpoint: String -) diff --git a/wonderwalled-idporten/src/main/kotlin/io/nais/Wonderwalled.kt b/wonderwalled-idporten/src/main/kotlin/io/nais/Wonderwalled.kt new file mode 100644 index 0000000..ed8244e --- /dev/null +++ b/wonderwalled-idporten/src/main/kotlin/io/nais/Wonderwalled.kt @@ -0,0 +1,80 @@ +package io.nais + +import com.auth0.jwk.JwkProviderBuilder +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.auth.authenticate +import io.ktor.auth.authentication +import io.ktor.auth.jwt.JWTPrincipal +import io.ktor.auth.jwt.jwt +import io.ktor.http.HttpStatusCode +import io.ktor.request.host +import io.ktor.request.path +import io.ktor.response.respond +import io.ktor.response.respondRedirect +import io.ktor.routing.get +import io.ktor.routing.route +import io.ktor.routing.routing +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.nais.common.commonSetup +import io.nais.common.getTokenInfo +import io.nais.common.requestHeaders +import java.net.URL +import java.util.concurrent.TimeUnit + +fun main() { + val config = Configuration() + + embeddedServer(Netty, port = config.port) { + wonderwalled(config) + }.start(wait = true) +} + +fun Application.wonderwalled(config: Configuration) { + val jwkProvider = JwkProviderBuilder(URL(config.idporten.openIdConfiguration.jwksUri)) + .cached(10, 1, TimeUnit.HOURS) + .rateLimited(10, 1, TimeUnit.MINUTES) + .build() + + commonSetup() + + authentication { + jwt { + verifier(jwkProvider, config.idporten.openIdConfiguration.issuer) { + withClaimPresence("client_id") + withClaim("client_id", config.idporten.clientId) + } + + validate { credentials -> JWTPrincipal(credentials.payload) } + + // challenge is called if the request authentication fails or is not provided + challenge { _, _ -> + val ingress = config.ingress.ifEmpty(defaultValue = { + "${call.request.local.scheme}://${call.request.host()}" + }) + + // redirect to login endpoint (wonderwall) and indicate that the user should be redirected back + // to the original request path after authentication + call.respondRedirect("$ingress/oauth2/login?redirect=${call.request.path()}") + } + } + } + + routing { + authenticate { + route("api") { + get("headers") { + call.respond(call.requestHeaders()) + } + + get("me") { + when (val tokenInfo = call.getTokenInfo()) { + null -> call.respond(HttpStatusCode.Unauthorized, "Could not find a valid principal") + else -> call.respond(tokenInfo) + } + } + } + } + } +} diff --git a/wonderwalled-idporten/src/main/resources/logback-remote.xml b/wonderwalled-idporten/src/main/resources/logback-remote.xml new file mode 100644 index 0000000..c36efc7 --- /dev/null +++ b/wonderwalled-idporten/src/main/resources/logback-remote.xml @@ -0,0 +1,18 @@ + + + + + [ignore] + + { "message": "%X - %m" } + + + + + + + + + + + diff --git a/wonderwalled-idporten/src/main/resources/logback.xml b/wonderwalled-idporten/src/main/resources/logback.xml new file mode 100644 index 0000000..4b0f48b --- /dev/null +++ b/wonderwalled-idporten/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + %green(%d{HH:mm:ss}){faint} %cyan([%-5.5t]){faint} %highlight(%0.-5p) %yellow(%-40.40logger{39}){cyan}: [%mdc] %m%n + + + + + + + + + + +