Skip to content
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

support https in all server types #47

Merged
merged 11 commits into from
Jun 13, 2021
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ As of version 0.3.3 the Docker image is published to the [GitHub Container Regis
:exclamation:

# mock-oauth2-server
A scriptable/customizable web server for testing HTTP clients using OAuth2/OpenID Connect or applications with a dependency to a running OAuth2 server (i.e. APIs requiring signed JWTs from a known issuer). The server also provides the neccessary endpoints for token validation (endpoint for JWKS) and ID Provider metadata discovery ("well-known" endpoints providing server metadata)
A scriptable/customizable web server for testing HTTP clients using OAuth2/OpenID Connect or applications with a dependency to a running OAuth2 server (i.e. APIs requiring signed JWTs from a known issuer). The server also provides the necessary endpoints for token validation (endpoint for JWKS) and ID Provider metadata discovery ("well-known" endpoints providing server metadata)

**mock-oauth2-server** is written in Kotlin using the great [OkHttp MockWebServer](https://github.com/square/okhttp/tree/master/mockwebserver) as the underlying server library and can be used in unit/integration tests in both **Java** and **Kotlin** or in any language as a standalone server in e.g. docker-compose.

Expand Down Expand Up @@ -35,7 +35,7 @@ The motivation behind this library is to provide a setup such that application d
* Verify expected requests made to the server
* Customizable through exposure of underlying [OkHttp MockWebServer](https://github.com/square/okhttp/tree/master/mockwebserver)
* **Standalone support** - i.e. run as application in IDE, run inside your app, or as a Docker image (provided)
* **OAuth2 Client Debugger** - e.g. support for triggering OIDC Auth Code Flow and receiving callback in debugger app, view token reponse from server (intended for standalone support)
* **OAuth2 Client Debugger** - e.g. support for triggering OIDC Auth Code Flow and receiving callback in debugger app, view token response from server (intended for standalone support)



Expand Down Expand Up @@ -334,6 +334,73 @@ services:
The debugger is a OAuth2 client implementing the `authorization_code` flow with a UI for debugging (e.g. request parameters).
Point your browser to [http://localhost:8080/default/debugger](http://localhost:8080/default/debugger) to check it out.

### Enabling HTTPS

In order to enable HTTPS you can either provide your own keystore or let the server generate one for you.

#### Unit tests

You need to supply the server with an SSL config, in order to do that you must specify your chosen server type in `OAuth2Config` and
pass in the SSL config to your server.

*Generate keystore:*
```kotlin
val ssl = Ssl()
val server = MockOAuth2Server(
OAuth2Config(httpServer = MockWebServerWrapper(ssl))
)
```
*This will generate a SSL certificate for `localhost` and can be added to your client's truststore by getting the ssl config:
`ssl.sslKeystore.keyStore`*

*Bring your own:*
```kotlin
val ssl = Ssl(
SslKeystore(
keyPassword = "",
keystoreFile = File("src/test/resources/localhost.p12"),
keystorePassword = "",
keystoreType = SslKeystore.KeyStoreType.PKCS12
)
)
val server = MockOAuth2Server(
OAuth2Config(httpServer = MockWebServerWrapper(ssl))
)
```

#### Docker / Standalone mode - JSON_CONFIG

In order to enable HTTPS for the server in Docker or standalone mode
you can either make the server generate the keystore or bring your own.

*Generate keystore:*

```json
{
"httpServer" : {
"type" : "NettyWrapper",
"ssl" : {}
}
}
```

*Bring your own:*

```json

{
"httpServer" : {
"type" : "NettyWrapper",
"ssl" : {
"keyPassword" : "",
"keystoreFile" : "src/test/resources/localhost.p12",
"keystoreType" : "PKCS12",
"keystorePassword" : ""
}
}
}
```

## 👥 Contact

This project is currently maintained by the organisation [@navikt](https://github.com/navikt).
Expand Down
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import java.time.Duration
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask




val assertjVersion = "3.19.0"
val kotlinLoggingVersion = "2.0.6"
val logbackVersion = "1.2.3"
Expand All @@ -12,6 +15,7 @@ val junitJupiterVersion = "5.7.2"
val kotlinVersion = "1.5.10"
val freemarkerVersion = "2.3.31"
val kotestVersion = "4.6.0"
val bouncyCastleVersion = "1.68"

val mavenRepoBaseUrl = "https://oss.sonatype.org"
val mainClassKt = "no.nav.security.mock.oauth2.StandaloneMockOAuth2ServerKt"
Expand Down Expand Up @@ -61,6 +65,7 @@ dependencies {
implementation("io.github.microutils:kotlin-logging:$kotlinLoggingVersion")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
implementation("org.freemarker:freemarker:$freemarkerVersion")
implementation("org.bouncycastle:bcpkix-jdk15on:$bouncyCastleVersion")
testImplementation("org.assertj:assertj-core:$assertjVersion")
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion")
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitJupiterVersion")
Expand Down
36 changes: 31 additions & 5 deletions src/main/kotlin/no/nav/security/mock/oauth2/OAuth2Config.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
package no.nav.security.mock.oauth2

import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import no.nav.security.mock.oauth2.http.MockWebServerWrapper
import no.nav.security.mock.oauth2.http.NettyWrapper
import no.nav.security.mock.oauth2.http.OAuth2HttpServer
import no.nav.security.mock.oauth2.http.Ssl
import no.nav.security.mock.oauth2.http.SslKeystore
import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
import no.nav.security.mock.oauth2.token.RequestMappingTokenCallback
import java.io.File

data class OAuth2Config @JvmOverloads constructor(
val interactiveLogin: Boolean = false,
Expand All @@ -29,11 +32,34 @@ data class OAuth2Config @JvmOverloads constructor(
NettyWrapper
}

data class ServerConfig(
val type: ServerType,
val ssl: SslConfig? = null
)

data class SslConfig(
val keyPassword: String = "",
val keystoreFile: File? = null,
val keystoreType: SslKeystore.KeyStoreType = SslKeystore.KeyStoreType.PKCS12,
val keystorePassword: String = ""
) {
fun ssl() = Ssl(sslKeyStore())

private fun sslKeyStore() =
if (keystoreFile == null) SslKeystore() else SslKeystore(keyPassword, keystoreFile, keystoreType, keystorePassword)
}

override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OAuth2HttpServer {
return when (p.readValueAs<ServerType>(object : TypeReference<ServerType>() {})) {
ServerType.NettyWrapper -> NettyWrapper()
ServerType.MockWebServerWrapper -> MockWebServerWrapper()
else -> throw IllegalArgumentException("unsupported httpServer specified in config")
val node: JsonNode = p.readValueAsTree()
val serverConfig: ServerConfig = if (node.isObject) {
p.codec.treeToValue(node, ServerConfig::class.java)
} else {
ServerConfig(ServerType.valueOf(node.textValue()))
}
val ssl: Ssl? = serverConfig.ssl?.ssl()
return when (serverConfig.type) {
ServerType.NettyWrapper -> NettyWrapper(ssl)
ServerType.MockWebServerWrapper -> MockWebServerWrapper(ssl)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,18 @@ interface OAuth2HttpServer : AutoCloseable {
fun url(path: String): HttpUrl
}

class MockWebServerWrapper : OAuth2HttpServer {
class MockWebServerWrapper@JvmOverloads constructor(
val ssl: Ssl? = null
) : OAuth2HttpServer {
val mockWebServer: MockWebServer = MockWebServer()

override fun start(inetAddress: InetAddress, port: Int, requestHandler: RequestHandler): OAuth2HttpServer = apply {
mockWebServer.start(inetAddress, port)
log.debug("started server on address=$inetAddress and port=${mockWebServer.port}")
mockWebServer.dispatcher = MockWebServerDispatcher(requestHandler)
if (ssl != null) {
mockWebServer.useHttps(ssl.sslContext().socketFactory, false)
}
log.debug("started server on address=$inetAddress and port=${mockWebServer.port}, httpsEnabled=${ssl != null}")
}

override fun stop(): OAuth2HttpServer = apply {
Expand Down Expand Up @@ -94,7 +99,9 @@ class MockWebServerWrapper : OAuth2HttpServer {
}
}

class NettyWrapper : OAuth2HttpServer {
class NettyWrapper @JvmOverloads constructor(
val ssl: Ssl? = null
) : OAuth2HttpServer {
private val masterGroup = NioEventLoopGroup()
private val workerGroup = NioEventLoopGroup()
private var closeFuture: ChannelFuture? = null
Expand All @@ -109,6 +116,9 @@ class NettyWrapper : OAuth2HttpServer {
.childHandler(
object : ChannelInitializer<SocketChannel>() {
public override fun initChannel(ch: SocketChannel) {
if (ssl != null) {
ch.pipeline().addFirst("ssl", ssl.nettySslHandler())
}
ch.pipeline().addLast("codec", HttpServerCodec())
ch.pipeline().addLast("keepAlive", HttpServerKeepAliveHandler())
ch.pipeline().addLast("aggregator", HttpObjectAggregator(Int.MAX_VALUE))
Expand Down Expand Up @@ -136,14 +146,21 @@ class NettyWrapper : OAuth2HttpServer {
override fun port(): Int = if (port > 0) port else address.port

override fun url(path: String): HttpUrl {
val scheme = if (ssl != null) {
"https"
} else {
"http"
}
return HttpUrl.Builder()
.scheme("http")
.scheme(scheme)
.host(address.address.canonicalHostName)
.port(port())
.build()
.resolve(path)!!
}

private fun Ssl.nettySslHandler(): SslHandler = SslHandler(sslEngine())

internal class RouterChannelHandler(val requestHandler: RequestHandler) : SimpleChannelInboundHandler<FullHttpRequest>() {

override fun channelRead0(ctx: ChannelHandlerContext, request: FullHttpRequest) {
Expand Down
128 changes: 128 additions & 0 deletions src/main/kotlin/no/nav/security/mock/oauth2/http/Ssl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package no.nav.security.mock.oauth2.http

import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.AlgorithmIdentifier
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier
import org.bouncycastle.asn1.x509.BasicConstraints
import org.bouncycastle.asn1.x509.ExtendedKeyUsage
import org.bouncycastle.asn1.x509.Extension
import org.bouncycastle.asn1.x509.GeneralName
import org.bouncycastle.asn1.x509.GeneralNames
import org.bouncycastle.asn1.x509.KeyPurposeId
import org.bouncycastle.asn1.x509.KeyUsage
import org.bouncycastle.asn1.x509.SubjectKeyIdentifier
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.cert.X509ExtensionUtils
import org.bouncycastle.cert.X509v3CertificateBuilder
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.ContentSigner
import org.bouncycastle.operator.bc.BcDigestCalculatorProvider
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import java.io.File
import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PublicKey
import java.security.cert.X509Certificate
import java.time.Duration
import java.time.Instant
import java.util.Date
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine

class Ssl @JvmOverloads constructor(
val sslKeystore: SslKeystore = SslKeystore()
) {
fun sslEngine(): SSLEngine = sslContext().createSSLEngine().apply {
useClientMode = false
needClientAuth = false
}

fun sslContext(): SSLContext {
val keyManager = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
init(sslKeystore.keyStore, sslKeystore.keyPassword.toCharArray())
}
return SSLContext.getInstance("TLS").apply {
init(keyManager.keyManagers, null, null)
}
}
}

class SslKeystore @JvmOverloads constructor(
val keyPassword: String = "",
val keyStore: KeyStore = generate("localhost", keyPassword)
) {
@JvmOverloads constructor(
keyPassword: String,
keystoreFile: File,
keystoreType: KeyStoreType = KeyStoreType.PKCS12,
keystorePassword: String = "",
) : this(keyPassword, keyStore(keystoreFile, keystoreType, keystorePassword))

enum class KeyStoreType {
PKCS12,
JKS
}

companion object {
private const val CERT_SIGNATURE_ALG = "SHA256withRSA"
private const val KEY_ALG = "RSA"
private const val KEY_SIZE = 2048

fun generate(hostname: String, keyPassword: String): KeyStore {
val keyPair = KeyPairGenerator.getInstance(KEY_ALG).apply { initialize(KEY_SIZE) }.generateKeyPair()
val cert = keyPair.toX509Certificate(hostname)
return KeyStore.getInstance(KeyStoreType.PKCS12.name).apply {
this.load(null)
this.setKeyEntry(hostname, keyPair.private, keyPassword.toCharArray(), arrayOf(cert))
}
}

private fun keyStore(
keystoreFile: File,
keystoreType: KeyStoreType = KeyStoreType.PKCS12,
keystorePassword: String = "",
) = KeyStore.getInstance(keystoreType.name).apply {
keystoreFile.inputStream().use {
load(it, keystorePassword.toCharArray())
}
}

private fun KeyPair.toX509Certificate(cn: String, expiry: Duration = Duration.ofDays(365)): X509Certificate {
val now = Instant.now()
val x500Name = X500Name("CN=$cn")
val contentSigner: ContentSigner = JcaContentSignerBuilder(CERT_SIGNATURE_ALG).build(this.private)
val certificateHolder = JcaX509v3CertificateBuilder(
x500Name,
BigInteger.valueOf(now.toEpochMilli()),
Date.from(now),
Date.from(now.plus(expiry)),
x500Name,
this.public
).addExtensions(cn, this.public).build(contentSigner)
return JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(certificateHolder)
}

private fun X509v3CertificateBuilder.addExtensions(cn: String, publicKey: PublicKey) = apply {
addExtension(Extension.subjectKeyIdentifier, false, publicKey.createSubjectKeyId())
.addExtension(Extension.authorityKeyIdentifier, false, publicKey.createAuthorityKeyId())
.addExtension(Extension.basicConstraints, true, BasicConstraints(true))
.addExtension(Extension.subjectAlternativeName, false, GeneralNames(GeneralName(GeneralName.dNSName, cn)))
.addExtension(Extension.keyUsage, false, KeyUsage(KeyUsage.digitalSignature))
.addExtension(Extension.extendedKeyUsage, false, ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth))
}

private fun PublicKey.createSubjectKeyId(): SubjectKeyIdentifier =
X509ExtensionUtils(digestCalculator()).createSubjectKeyIdentifier(SubjectPublicKeyInfo.getInstance(encoded))

private fun PublicKey.createAuthorityKeyId(): AuthorityKeyIdentifier =
X509ExtensionUtils(digestCalculator()).createAuthorityKeyIdentifier(SubjectPublicKeyInfo.getInstance(encoded))

private fun digestCalculator() = BcDigestCalculatorProvider().get(AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1))
}
}
Loading