├── .gitignore
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .editorconfig
├── LICENSE
├── test-helpers.sh
├── .github
└── workflows
│ └── gradle.yml
├── src
├── test
│ └── java
│ │ └── com
│ │ └── github
│ │ └── masonm
│ │ ├── TestAuthHeader.java
│ │ ├── JwtTest.java
│ │ ├── JwtMatcherExtensionTest.java
│ │ └── JwtStubMappingTransformerTest.java
└── main
│ └── java
│ └── com
│ └── github
│ └── masonm
│ ├── JwtExtensionStandalone.java
│ ├── Jwt.java
│ ├── JwtMatcherExtension.java
│ └── JwtStubMappingTransformer.java
├── wiremock-jwt-extension.iml
├── test-request-match.sh
├── test-stub-transformer.sh
├── gradlew.bat
├── README.md
└── gradlew
/.gitignore:
--------------------------------------------------------------------------------
1 | /.gradle/
2 | /.idea/
3 | /build/
4 | private.key
5 | /mappings/
6 | /__files/
7 | /docker/
8 | /out/
9 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MasonM/wiremock-jwt-extension/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | end_of_line = lf
7 | indent_style = space
8 | indent_size = 4
9 |
10 | [{*.yml,*.iml,gradlew}]
11 | indent_size = 2
12 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2017 Mason Malone
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
15 |
--------------------------------------------------------------------------------
/test-helpers.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | VERSION=1.0.0
4 |
5 | launchWiremock() {
6 | echo "Launching Wiremock and setting up proxying"
7 | #java -cp wiremock-standalone-3.0.4.jar:build/libs/wiremock-jwt-extension-${VERSION}.jar wiremock.Run --extensions="com.github.masonm.JwtMatcherExtension,com.github.masonm.JwtStubMappingTransformer" &
8 | java -jar build/libs/wiremock-jwt-extension-${VERSION}-standalone.jar &
9 | WIREMOCK_PID=$!
10 | trap "kill $WIREMOCK_PID" exit
11 |
12 | echo -n "Waiting for Wiremock to start up."
13 | until $(curl --output /dev/null --silent --head ${WIREMOCK_BASE_URL}); do
14 | echo -n '.'
15 | sleep 1
16 | done
17 | }
18 |
--------------------------------------------------------------------------------
/.github/workflows/gradle.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a Java project with Gradle
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle
3 |
4 | name: Java CI with Gradle
5 |
6 | on:
7 | push:
8 | branches: [ master ]
9 | pull_request:
10 | branches: [ master ]
11 |
12 | jobs:
13 | build:
14 |
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 | - name: Set up Java
20 | uses: actions/setup-java@v3
21 | with:
22 | java-version: '11'
23 | distribution: 'temurin'
24 | - name: Build with Gradle
25 | run: ./gradlew build
26 |
--------------------------------------------------------------------------------
/src/test/java/com/github/masonm/TestAuthHeader.java:
--------------------------------------------------------------------------------
1 | package com.github.masonm;
2 |
3 | import org.apache.commons.codec.binary.Base64;
4 |
5 | /**
6 | * Generates an Authorization header string for testing purposes
7 | */
8 | public class TestAuthHeader {
9 | private final String header;
10 | private final String payload;
11 |
12 | public TestAuthHeader(String header, String payload) {
13 | this.header = header;
14 | this.payload = payload;
15 | }
16 |
17 | private String encode(String value) {
18 | return Base64.encodeBase64URLSafeString(value.getBytes());
19 | }
20 |
21 | public String toString() {
22 | return "Bearer " + encode(header) + "." + encode(payload) + ".dummy_signature";
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/main/java/com/github/masonm/JwtExtensionStandalone.java:
--------------------------------------------------------------------------------
1 | package com.github.masonm;
2 |
3 | import com.github.tomakehurst.wiremock.standalone.WireMockServerRunner;
4 |
5 | import java.util.Arrays;
6 |
7 | public final class JwtExtensionStandalone {
8 | private JwtExtensionStandalone() {}
9 |
10 | // When WireMock is run in standalone mode, WireMockServerRunner.run() is the entry point,
11 | // so we just delegate to that, passing along a CSV string with each extension class to load
12 | public static void main(String... args) {
13 | String[] newArgs = Arrays.copyOf(args, args.length + 1);
14 | newArgs[args.length] = "--extensions=" + JwtMatcherExtension.class.getName() + "," + JwtStubMappingTransformer.class.getName();
15 | new WireMockServerRunner().run(newArgs);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/wiremock-jwt-extension.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test-request-match.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | source test-helpers.sh
6 | WIREMOCK_BASE_URL=http://localhost:8080
7 |
8 | launchWiremock
9 |
10 | echo -e "done\n\nCreating proxy mapping"
11 | curl -d@- http://localhost:8080/__admin/mappings <<-EOD
12 | {
13 | "request" : {
14 | "url" : "/some_url",
15 | "method" : "GET",
16 | "customMatcher" : {
17 | "name" : "jwt-matcher",
18 | "parameters" : {
19 | "header" : {
20 | "alg" : "HS256",
21 | "typ": "JWT"
22 | },
23 | "payload": {
24 | "name" : "John Doe",
25 | "aud": ["foo", "bar"]
26 | }
27 | }
28 | }
29 | },
30 | "response" : {
31 | "status" : 200,
32 | "body": "success"
33 | }
34 | }
35 | EOD
36 |
37 | echo -e "done\n\nMaking request"
38 | curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJhdWQiOlsiZm9vIiwiYmFyIl19.aqa_OxjpGtC4nHVCUlCqmiNHOAYK6VFyq2HFsOOmJIY' http://localhost:8080/some_url
39 |
--------------------------------------------------------------------------------
/test-stub-transformer.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | source test-helpers.sh
6 |
7 | PROXY_BASE_URL="https://wiremock.org"
8 | WIREMOCK_BASE_URL=http://localhost:8080
9 |
10 | launchWiremock
11 |
12 | echo -e "done\n\nCreating proxy mapping"
13 | curl -s -d '{
14 | "request": { "urlPattern": ".*" },
15 | "response": {
16 | "proxyBaseUrl": "'${PROXY_BASE_URL}'"
17 | }
18 | }' http://localhost:8080/__admin/mappings > /dev/null
19 |
20 |
21 | echo -e "done\n\nMaking request"
22 | TEST_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o'
23 | curl -s -H "Authorization: Bearer ${TEST_TOKEN}" "${WIREMOCK_BASE_URL}/robots.txt?foo=bar" > /dev/null
24 |
25 | REQUEST_JSON='{
26 | "outputFormat": "full",
27 | "persist": false,
28 | "transformers": ["jwt-stub-mapping-transformer"],
29 | "transformerParameters": {
30 | "payloadFields": ["iat", "user"]
31 | },
32 | "captureHeaders": {
33 | "Host": { "caseInsensitive": true },
34 | "Authorization": { "caseInsensitive": true }
35 | },
36 | "extractBodyCriteria": {
37 | "textSizeThreshold": "2000"
38 | }
39 | }'
40 | echo -e "done\n\nCalling snapshot API with '${REQUEST_JSON}'"
41 | curl -X POST -d "${REQUEST_JSON}" "${WIREMOCK_BASE_URL}/__admin/recordings/snapshot"
42 |
--------------------------------------------------------------------------------
/src/main/java/com/github/masonm/Jwt.java:
--------------------------------------------------------------------------------
1 | package com.github.masonm;
2 |
3 | import org.apache.commons.codec.binary.Base64;
4 | import com.fasterxml.jackson.databind.JsonNode;
5 | import com.fasterxml.jackson.databind.ObjectMapper;
6 | import com.fasterxml.jackson.databind.node.MissingNode;
7 |
8 | import java.io.IOException;
9 |
10 | public class Jwt {
11 | private final JsonNode header;
12 | private final JsonNode payload;
13 |
14 | public Jwt(String token) {
15 | String[] parts = token.split("\\.");
16 | if (parts.length < 2) {
17 | this.header = MissingNode.getInstance();
18 | this.payload = MissingNode.getInstance();
19 | } else {
20 | this.header = parsePart(parts[0]);
21 | this.payload = parsePart(parts[1]);
22 | }
23 | }
24 |
25 | public static Jwt fromAuthHeader(String authHeader) {
26 | // Per RFC7235, the syntax for the credentials in the Authorization header is:
27 | // credentials = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
28 | // where auth-scheme is usually "Bearer" for JWT, but some APIs use "JWT" instead.
29 | int separatorIndex = authHeader.indexOf(" ");
30 | if (separatorIndex == -1) {
31 | // Missing auth-scheme. Not standard, but try parsing it anyway.
32 | return new Jwt(authHeader);
33 | } else {
34 | return new Jwt(authHeader.substring(separatorIndex + 1));
35 | }
36 | }
37 |
38 | private JsonNode parsePart(String part) {
39 | byte[] decodedJwtBody;
40 | try {
41 | decodedJwtBody = Base64.decodeBase64(part);
42 | } catch (IllegalArgumentException ex) {
43 | return MissingNode.getInstance();
44 | }
45 |
46 | try {
47 | ObjectMapper mapper = new ObjectMapper();
48 | return mapper.readValue(decodedJwtBody, JsonNode.class);
49 | } catch (IOException ioe) {
50 | return MissingNode.getInstance();
51 | }
52 | }
53 |
54 | public JsonNode getPayload() {
55 | return payload;
56 | }
57 |
58 | public JsonNode getHeader() {
59 | return header;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/main/java/com/github/masonm/JwtMatcherExtension.java:
--------------------------------------------------------------------------------
1 | package com.github.masonm;
2 |
3 | import com.fasterxml.jackson.core.type.TypeReference;
4 | import com.fasterxml.jackson.databind.JsonNode;
5 | import com.fasterxml.jackson.databind.ObjectMapper;
6 | import com.github.tomakehurst.wiremock.extension.Parameters;
7 | import com.github.tomakehurst.wiremock.http.Request;
8 | import com.github.tomakehurst.wiremock.matching.MatchResult;
9 | import com.github.tomakehurst.wiremock.matching.RequestMatcherExtension;
10 | import com.github.tomakehurst.wiremock.matching.RequestPattern;
11 |
12 | import java.util.Map;
13 | import java.util.Objects;
14 |
15 | import static com.github.tomakehurst.wiremock.matching.MatchResult.noMatch;
16 | import static com.github.tomakehurst.wiremock.matching.MatchResult.exactMatch;
17 |
18 | public class JwtMatcherExtension extends RequestMatcherExtension {
19 | public static final String NAME = "jwt-matcher";
20 | public static final String PARAM_NAME_PAYLOAD = "payload";
21 | public static final String PARAM_NAME_HEADER = "header";
22 | public static final String PARAM_NAME_REQUEST = "request";
23 |
24 | @Override
25 | public String getName() {
26 | return "jwt-matcher";
27 | }
28 |
29 | @Override
30 | public MatchResult match(Request request, Parameters parameters) {
31 | if (!parameters.containsKey(PARAM_NAME_PAYLOAD) && !parameters.containsKey(PARAM_NAME_HEADER)) {
32 | return noMatch();
33 | }
34 |
35 | if (parameters.containsKey(PARAM_NAME_REQUEST)) {
36 | Parameters requestParameters = Parameters.of(parameters.get(PARAM_NAME_REQUEST));
37 | RequestPattern requestPattern = requestParameters.as(RequestPattern.class);
38 | if (!requestPattern.match(request).isExactMatch()) {
39 | return noMatch();
40 | }
41 | }
42 |
43 | String authString = request.getHeader("Authorization");
44 | if (authString == null || authString.isEmpty()) {
45 | return noMatch();
46 | }
47 |
48 | Jwt token = Jwt.fromAuthHeader(authString);
49 |
50 | if (
51 | parameters.containsKey(PARAM_NAME_HEADER) &&
52 | !matchParams(token.getHeader(), parameters.get(PARAM_NAME_HEADER))
53 | ) {
54 | return noMatch();
55 | }
56 |
57 | if (
58 | parameters.containsKey(PARAM_NAME_PAYLOAD) &&
59 | !matchParams(token.getPayload(), parameters.get(PARAM_NAME_PAYLOAD))
60 | ) {
61 | return noMatch();
62 | }
63 |
64 | return exactMatch();
65 | }
66 |
67 | private boolean matchParams(JsonNode tokenValues, Object parameters) {
68 | Map parameterMap = new ObjectMapper().convertValue(
69 | parameters,
70 | new TypeReference