Skip to content

Commit 66e7569

Browse files
authoredJun 13, 2021
support https in all server types (#47)
* feature: add https support for NettyWrapper * feature: add https support for MockWebServerWrapper * enabled by providing Ssl constructor param * generate cert and keystore if not provided in Ssl constructor * support ssl with json config
1 parent 84d9cba commit 66e7569

File tree

10 files changed

+429
-16
lines changed

10 files changed

+429
-16
lines changed
 

‎README.md

+69-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ As of version 0.3.3 the Docker image is published to the [GitHub Container Regis
77
:exclamation:
88

99
# mock-oauth2-server
10-
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)
10+
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)
1111

1212
**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.
1313

@@ -35,7 +35,7 @@ The motivation behind this library is to provide a setup such that application d
3535
* Verify expected requests made to the server
3636
* Customizable through exposure of underlying [OkHttp MockWebServer](https://github.com/square/okhttp/tree/master/mockwebserver)
3737
* **Standalone support** - i.e. run as application in IDE, run inside your app, or as a Docker image (provided)
38-
* **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)
38+
* **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)
3939

4040

4141

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

337+
### Enabling HTTPS
338+
339+
In order to enable HTTPS you can either provide your own keystore or let the server generate one for you.
340+
341+
#### Unit tests
342+
343+
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
344+
pass in the SSL config to your server.
345+
346+
*Generate keystore:*
347+
```kotlin
348+
val ssl = Ssl()
349+
val server = MockOAuth2Server(
350+
OAuth2Config(httpServer = MockWebServerWrapper(ssl))
351+
)
352+
```
353+
*This will generate a SSL certificate for `localhost` and can be added to your client's truststore by getting the ssl config:
354+
`ssl.sslKeystore.keyStore`*
355+
356+
*Bring your own:*
357+
```kotlin
358+
val ssl = Ssl(
359+
SslKeystore(
360+
keyPassword = "",
361+
keystoreFile = File("src/test/resources/localhost.p12"),
362+
keystorePassword = "",
363+
keystoreType = SslKeystore.KeyStoreType.PKCS12
364+
)
365+
)
366+
val server = MockOAuth2Server(
367+
OAuth2Config(httpServer = MockWebServerWrapper(ssl))
368+
)
369+
```
370+
371+
#### Docker / Standalone mode - JSON_CONFIG
372+
373+
In order to enable HTTPS for the server in Docker or standalone mode
374+
you can either make the server generate the keystore or bring your own.
375+
376+
*Generate keystore:*
377+
378+
```json
379+
{
380+
"httpServer" : {
381+
"type" : "NettyWrapper",
382+
"ssl" : {}
383+
}
384+
}
385+
```
386+
387+
*Bring your own:*
388+
389+
```json
390+
391+
{
392+
"httpServer" : {
393+
"type" : "NettyWrapper",
394+
"ssl" : {
395+
"keyPassword" : "",
396+
"keystoreFile" : "src/test/resources/localhost.p12",
397+
"keystoreType" : "PKCS12",
398+
"keystorePassword" : ""
399+
}
400+
}
401+
}
402+
```
403+
337404
## 👥 Contact
338405

339406
This project is currently maintained by the organisation [@navikt](https://github.com/navikt).

‎build.gradle.kts

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import java.time.Duration
22
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
33

4+
5+
6+
47
val assertjVersion = "3.19.0"
58
val kotlinLoggingVersion = "2.0.6"
69
val logbackVersion = "1.2.3"
@@ -12,6 +15,7 @@ val junitJupiterVersion = "5.7.2"
1215
val kotlinVersion = "1.5.10"
1316
val freemarkerVersion = "2.3.31"
1417
val kotestVersion = "4.6.0"
18+
val bouncyCastleVersion = "1.68"
1519

1620
val mavenRepoBaseUrl = "https://oss.sonatype.org"
1721
val mainClassKt = "no.nav.security.mock.oauth2.StandaloneMockOAuth2ServerKt"
@@ -61,6 +65,7 @@ dependencies {
6165
implementation("io.github.microutils:kotlin-logging:$kotlinLoggingVersion")
6266
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
6367
implementation("org.freemarker:freemarker:$freemarkerVersion")
68+
implementation("org.bouncycastle:bcpkix-jdk15on:$bouncyCastleVersion")
6469
testImplementation("org.assertj:assertj-core:$assertjVersion")
6570
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion")
6671
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitJupiterVersion")

‎src/main/kotlin/no/nav/security/mock/oauth2/OAuth2Config.kt

+31-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package no.nav.security.mock.oauth2
22

33
import com.fasterxml.jackson.core.JsonParser
4-
import com.fasterxml.jackson.core.type.TypeReference
54
import com.fasterxml.jackson.databind.DeserializationContext
65
import com.fasterxml.jackson.databind.JsonDeserializer
6+
import com.fasterxml.jackson.databind.JsonNode
77
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
88
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
99
import com.fasterxml.jackson.module.kotlin.readValue
1010
import no.nav.security.mock.oauth2.http.MockWebServerWrapper
1111
import no.nav.security.mock.oauth2.http.NettyWrapper
1212
import no.nav.security.mock.oauth2.http.OAuth2HttpServer
13+
import no.nav.security.mock.oauth2.http.Ssl
14+
import no.nav.security.mock.oauth2.http.SslKeystore
1315
import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
1416
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1517
import no.nav.security.mock.oauth2.token.RequestMappingTokenCallback
18+
import java.io.File
1619

1720
data class OAuth2Config @JvmOverloads constructor(
1821
val interactiveLogin: Boolean = false,
@@ -29,11 +32,34 @@ data class OAuth2Config @JvmOverloads constructor(
2932
NettyWrapper
3033
}
3134

35+
data class ServerConfig(
36+
val type: ServerType,
37+
val ssl: SslConfig? = null
38+
)
39+
40+
data class SslConfig(
41+
val keyPassword: String = "",
42+
val keystoreFile: File? = null,
43+
val keystoreType: SslKeystore.KeyStoreType = SslKeystore.KeyStoreType.PKCS12,
44+
val keystorePassword: String = ""
45+
) {
46+
fun ssl() = Ssl(sslKeyStore())
47+
48+
private fun sslKeyStore() =
49+
if (keystoreFile == null) SslKeystore() else SslKeystore(keyPassword, keystoreFile, keystoreType, keystorePassword)
50+
}
51+
3252
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OAuth2HttpServer {
33-
return when (p.readValueAs<ServerType>(object : TypeReference<ServerType>() {})) {
34-
ServerType.NettyWrapper -> NettyWrapper()
35-
ServerType.MockWebServerWrapper -> MockWebServerWrapper()
36-
else -> throw IllegalArgumentException("unsupported httpServer specified in config")
53+
val node: JsonNode = p.readValueAsTree()
54+
val serverConfig: ServerConfig = if (node.isObject) {
55+
p.codec.treeToValue(node, ServerConfig::class.java)
56+
} else {
57+
ServerConfig(ServerType.valueOf(node.textValue()))
58+
}
59+
val ssl: Ssl? = serverConfig.ssl?.ssl()
60+
return when (serverConfig.type) {
61+
ServerType.NettyWrapper -> NettyWrapper(ssl)
62+
ServerType.MockWebServerWrapper -> MockWebServerWrapper(ssl)
3763
}
3864
}
3965
}

‎src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpServer.kt

+21-4
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,18 @@ interface OAuth2HttpServer : AutoCloseable {
5757
fun url(path: String): HttpUrl
5858
}
5959

60-
class MockWebServerWrapper : OAuth2HttpServer {
60+
class MockWebServerWrapper@JvmOverloads constructor(
61+
val ssl: Ssl? = null
62+
) : OAuth2HttpServer {
6163
val mockWebServer: MockWebServer = MockWebServer()
6264

6365
override fun start(inetAddress: InetAddress, port: Int, requestHandler: RequestHandler): OAuth2HttpServer = apply {
6466
mockWebServer.start(inetAddress, port)
65-
log.debug("started server on address=$inetAddress and port=${mockWebServer.port}")
6667
mockWebServer.dispatcher = MockWebServerDispatcher(requestHandler)
68+
if (ssl != null) {
69+
mockWebServer.useHttps(ssl.sslContext().socketFactory, false)
70+
}
71+
log.debug("started server on address=$inetAddress and port=${mockWebServer.port}, httpsEnabled=${ssl != null}")
6772
}
6873

6974
override fun stop(): OAuth2HttpServer = apply {
@@ -94,7 +99,9 @@ class MockWebServerWrapper : OAuth2HttpServer {
9499
}
95100
}
96101

97-
class NettyWrapper : OAuth2HttpServer {
102+
class NettyWrapper @JvmOverloads constructor(
103+
val ssl: Ssl? = null
104+
) : OAuth2HttpServer {
98105
private val masterGroup = NioEventLoopGroup()
99106
private val workerGroup = NioEventLoopGroup()
100107
private var closeFuture: ChannelFuture? = null
@@ -109,6 +116,9 @@ class NettyWrapper : OAuth2HttpServer {
109116
.childHandler(
110117
object : ChannelInitializer<SocketChannel>() {
111118
public override fun initChannel(ch: SocketChannel) {
119+
if (ssl != null) {
120+
ch.pipeline().addFirst("ssl", ssl.nettySslHandler())
121+
}
112122
ch.pipeline().addLast("codec", HttpServerCodec())
113123
ch.pipeline().addLast("keepAlive", HttpServerKeepAliveHandler())
114124
ch.pipeline().addLast("aggregator", HttpObjectAggregator(Int.MAX_VALUE))
@@ -136,14 +146,21 @@ class NettyWrapper : OAuth2HttpServer {
136146
override fun port(): Int = if (port > 0) port else address.port
137147

138148
override fun url(path: String): HttpUrl {
149+
val scheme = if (ssl != null) {
150+
"https"
151+
} else {
152+
"http"
153+
}
139154
return HttpUrl.Builder()
140-
.scheme("http")
155+
.scheme(scheme)
141156
.host(address.address.canonicalHostName)
142157
.port(port())
143158
.build()
144159
.resolve(path)!!
145160
}
146161

162+
private fun Ssl.nettySslHandler(): SslHandler = SslHandler(sslEngine())
163+
147164
internal class RouterChannelHandler(val requestHandler: RequestHandler) : SimpleChannelInboundHandler<FullHttpRequest>() {
148165

149166
override fun channelRead0(ctx: ChannelHandlerContext, request: FullHttpRequest) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package no.nav.security.mock.oauth2.http
2+
3+
import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers
4+
import org.bouncycastle.asn1.x500.X500Name
5+
import org.bouncycastle.asn1.x509.AlgorithmIdentifier
6+
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier
7+
import org.bouncycastle.asn1.x509.BasicConstraints
8+
import org.bouncycastle.asn1.x509.ExtendedKeyUsage
9+
import org.bouncycastle.asn1.x509.Extension
10+
import org.bouncycastle.asn1.x509.GeneralName
11+
import org.bouncycastle.asn1.x509.GeneralNames
12+
import org.bouncycastle.asn1.x509.KeyPurposeId
13+
import org.bouncycastle.asn1.x509.KeyUsage
14+
import org.bouncycastle.asn1.x509.SubjectKeyIdentifier
15+
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
16+
import org.bouncycastle.cert.X509ExtensionUtils
17+
import org.bouncycastle.cert.X509v3CertificateBuilder
18+
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
19+
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
20+
import org.bouncycastle.jce.provider.BouncyCastleProvider
21+
import org.bouncycastle.operator.ContentSigner
22+
import org.bouncycastle.operator.bc.BcDigestCalculatorProvider
23+
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
24+
import java.io.File
25+
import java.math.BigInteger
26+
import java.security.KeyPair
27+
import java.security.KeyPairGenerator
28+
import java.security.KeyStore
29+
import java.security.PublicKey
30+
import java.security.cert.X509Certificate
31+
import java.time.Duration
32+
import java.time.Instant
33+
import java.util.Date
34+
import javax.net.ssl.KeyManagerFactory
35+
import javax.net.ssl.SSLContext
36+
import javax.net.ssl.SSLEngine
37+
38+
class Ssl @JvmOverloads constructor(
39+
val sslKeystore: SslKeystore = SslKeystore()
40+
) {
41+
fun sslEngine(): SSLEngine = sslContext().createSSLEngine().apply {
42+
useClientMode = false
43+
needClientAuth = false
44+
}
45+
46+
fun sslContext(): SSLContext {
47+
val keyManager = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
48+
init(sslKeystore.keyStore, sslKeystore.keyPassword.toCharArray())
49+
}
50+
return SSLContext.getInstance("TLS").apply {
51+
init(keyManager.keyManagers, null, null)
52+
}
53+
}
54+
}
55+
56+
class SslKeystore @JvmOverloads constructor(
57+
val keyPassword: String = "",
58+
val keyStore: KeyStore = generate("localhost", keyPassword)
59+
) {
60+
@JvmOverloads constructor(
61+
keyPassword: String,
62+
keystoreFile: File,
63+
keystoreType: KeyStoreType = KeyStoreType.PKCS12,
64+
keystorePassword: String = "",
65+
) : this(keyPassword, keyStore(keystoreFile, keystoreType, keystorePassword))
66+
67+
enum class KeyStoreType {
68+
PKCS12,
69+
JKS
70+
}
71+
72+
companion object {
73+
private const val CERT_SIGNATURE_ALG = "SHA256withRSA"
74+
private const val KEY_ALG = "RSA"
75+
private const val KEY_SIZE = 2048
76+
77+
fun generate(hostname: String, keyPassword: String): KeyStore {
78+
val keyPair = KeyPairGenerator.getInstance(KEY_ALG).apply { initialize(KEY_SIZE) }.generateKeyPair()
79+
val cert = keyPair.toX509Certificate(hostname)
80+
return KeyStore.getInstance(KeyStoreType.PKCS12.name).apply {
81+
this.load(null)
82+
this.setKeyEntry(hostname, keyPair.private, keyPassword.toCharArray(), arrayOf(cert))
83+
}
84+
}
85+
86+
private fun keyStore(
87+
keystoreFile: File,
88+
keystoreType: KeyStoreType = KeyStoreType.PKCS12,
89+
keystorePassword: String = "",
90+
) = KeyStore.getInstance(keystoreType.name).apply {
91+
keystoreFile.inputStream().use {
92+
load(it, keystorePassword.toCharArray())
93+
}
94+
}
95+
96+
private fun KeyPair.toX509Certificate(cn: String, expiry: Duration = Duration.ofDays(365)): X509Certificate {
97+
val now = Instant.now()
98+
val x500Name = X500Name("CN=$cn")
99+
val contentSigner: ContentSigner = JcaContentSignerBuilder(CERT_SIGNATURE_ALG).build(this.private)
100+
val certificateHolder = JcaX509v3CertificateBuilder(
101+
x500Name,
102+
BigInteger.valueOf(now.toEpochMilli()),
103+
Date.from(now),
104+
Date.from(now.plus(expiry)),
105+
x500Name,
106+
this.public
107+
).addExtensions(cn, this.public).build(contentSigner)
108+
return JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(certificateHolder)
109+
}
110+
111+
private fun X509v3CertificateBuilder.addExtensions(cn: String, publicKey: PublicKey) = apply {
112+
addExtension(Extension.subjectKeyIdentifier, false, publicKey.createSubjectKeyId())
113+
.addExtension(Extension.authorityKeyIdentifier, false, publicKey.createAuthorityKeyId())
114+
.addExtension(Extension.basicConstraints, true, BasicConstraints(true))
115+
.addExtension(Extension.subjectAlternativeName, false, GeneralNames(GeneralName(GeneralName.dNSName, cn)))
116+
.addExtension(Extension.keyUsage, false, KeyUsage(KeyUsage.digitalSignature))
117+
.addExtension(Extension.extendedKeyUsage, false, ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth))
118+
}
119+
120+
private fun PublicKey.createSubjectKeyId(): SubjectKeyIdentifier =
121+
X509ExtensionUtils(digestCalculator()).createSubjectKeyIdentifier(SubjectPublicKeyInfo.getInstance(encoded))
122+
123+
private fun PublicKey.createAuthorityKeyId(): AuthorityKeyIdentifier =
124+
X509ExtensionUtils(digestCalculator()).createAuthorityKeyIdentifier(SubjectPublicKeyInfo.getInstance(encoded))
125+
126+
private fun digestCalculator() = BcDigestCalculatorProvider().get(AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1))
127+
}
128+
}

0 commit comments

Comments
 (0)
Please sign in to comment.