|
| 1 | +package net.netshot.netshot; |
| 2 | + |
| 3 | +import java.io.ByteArrayInputStream; |
| 4 | +import java.io.IOException; |
| 5 | +import java.io.InputStream; |
| 6 | +import java.math.BigInteger; |
| 7 | +import java.net.URI; |
| 8 | +import java.nio.charset.StandardCharsets; |
| 9 | +import java.security.GeneralSecurityException; |
| 10 | +import java.security.InvalidKeyException; |
| 11 | +import java.security.KeyFactory; |
| 12 | +import java.security.NoSuchAlgorithmException; |
| 13 | +import java.security.PrivateKey; |
| 14 | +import java.security.Signature; |
| 15 | +import java.security.SignatureException; |
| 16 | +import java.security.cert.Certificate; |
| 17 | +import java.security.cert.CertificateException; |
| 18 | +import java.security.cert.CertificateFactory; |
| 19 | +import java.security.interfaces.RSAPublicKey; |
| 20 | +import java.security.spec.InvalidKeySpecException; |
| 21 | +import java.security.spec.PKCS8EncodedKeySpec; |
| 22 | +import java.time.Instant; |
| 23 | +import java.time.temporal.ChronoUnit; |
| 24 | +import java.util.Base64; |
| 25 | +import java.util.HashMap; |
| 26 | +import java.util.Map; |
| 27 | + |
| 28 | +import org.glassfish.grizzly.http.server.HttpHandler; |
| 29 | +import org.glassfish.grizzly.http.server.HttpServer; |
| 30 | +import org.glassfish.grizzly.http.server.Request; |
| 31 | +import org.glassfish.grizzly.http.server.Response; |
| 32 | +import com.fasterxml.jackson.databind.node.JsonNodeFactory; |
| 33 | +import com.fasterxml.jackson.databind.node.ObjectNode; |
| 34 | + |
| 35 | +import jakarta.ws.rs.ForbiddenException; |
| 36 | +import jakarta.ws.rs.core.MediaType; |
| 37 | +import lombok.Getter; |
| 38 | +import lombok.Setter; |
| 39 | + |
| 40 | +public class FakeOidcIdpServer { |
| 41 | + |
| 42 | + public static class User { |
| 43 | + private String username; |
| 44 | + private String role; |
| 45 | + |
| 46 | + public User(String username, String role) { |
| 47 | + this.username = username; |
| 48 | + this.role = role; |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + final static private URI DEFAULT_BASE_URI = URI.create("http://localhost:8980/"); |
| 53 | + final static private String DISCOVERY_PATH = "/.well-known/openid-configuration"; |
| 54 | + final static private String AUTH_PATH = "/protocol/openid-connect/auth"; |
| 55 | + final static private String TOKEN_PATH = "/protocol/openid-connect/token"; |
| 56 | + final static private String JWKS_PATH = "/protocol/openid-connect/certs"; |
| 57 | + |
| 58 | + final static private String SAMPLE_KID = "ukbTXswbjExjfqPBKPREf8VULCASfzi05E97YKsj6g0"; |
| 59 | + |
| 60 | + |
| 61 | + // Sample RSA key (PCKS8, PEM-encoded) |
| 62 | + final static private String SAMPLE_PRIVATE_KEY = |
| 63 | + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDLFrHaHNdJAyV3" + |
| 64 | + "Tctaam0MX978CKEDKGlxeuRmSnjMVDfTljI7zThWowbJ4sqg6ICvDtTlfAAhIPxp" + |
| 65 | + "spPYHITyA79PV57/Y/k3Cz55DMpm19YwJUbEcHSn2Z6fIcOmXanPiNUB3A1Zx/Jx" + |
| 66 | + "ZHYPzgIaWGEwRTBECjSIlZwYM1iOVtZaMrOSqc5z2cfRVNfBYQ5/mDn3ZEGv+ny+" + |
| 67 | + "auVEAgmhmlaGxgX7mLToRBzv0t9TNfM8kosaYBG+QanCh92vsswEAASUDtzzkmiN" + |
| 68 | + "g1tQ0c8gew22hOJJnh/ohtn+mNHWkS5UISzBlpZWl9/6olv71s1tyvbZhfhyXp0x" + |
| 69 | + "FN6qsK7zAgMBAAECggEAApruBiInlHBKmAvl7c9+sNFya7sNzoIQTc92dzKn93Fy" + |
| 70 | + "U+MFDiWISvtry19lTqIc5uH8UvZ/wLnXf+DPRIIjjHOfp4kBHHD8xSF+S13U8A1l" + |
| 71 | + "wH4oUoqUwuoOPsFXbRHRHonrtzEXWyb7xUubt7RihevLUPiz17OZaYUg/vutotaU" + |
| 72 | + "p0pOiJ3OQStS4F44MIcc9xUUXo6j2OfkYRCIsgYhPdsjpGQhpEd6ANKPBkjqHoyQ" + |
| 73 | + "B47ctALf2nlwtjtn/Wa1sctBzy4Oy3F2guNtaZZGnuhk8NE8yv/pDgFmABE8+cAZ" + |
| 74 | + "9waivuf4OkbmRp7WWYIjA69bzcHJWWOZXjLHsPSHVQKBgQD8nLGbPTPwC2LK6nkk" + |
| 75 | + "8mkrGn130z9YX3zUXUuIXXbfc1IT0cBGgLk+gHKgo75yqlQCa7/Tkz2ZJA2eu0vV" + |
| 76 | + "D4IqpcgBCNlBKOIGG2ninvzy0dYzPP5LtwqFAjIxF63faMGU8+ym1/KMGzHqPoYz" + |
| 77 | + "BiDaWJgp+UrxwuigPeOOH50rFQKBgQDNz/g2G4+6+JCNfBlQOYubeLODrOVb8qEj" + |
| 78 | + "M2Gi1BUtqA+XmEElwtKWUXqgH+I9gKHRWo4AUD0Yi64f5T7zRwe0rDfUhDc67Wpz" + |
| 79 | + "8HVm98fTRK6VOdwXRt/1jAchWG07otz+KOA2ou3nQqMmY52tKdDz1mL6FzJ/fcAe" + |
| 80 | + "UyizQQtT5wKBgCVySPW9PdzAo1V3KpwqfyKPm7fOjd5Y0VVduxus1zlKjAk6F6mb" + |
| 81 | + "3VoBinx7qXiv/SIavOXtNr1j1c0I8LXVxbLyvlJA8IuzNsY2/BxG+zI3nuwbh4rL" + |
| 82 | + "yHhtGemjG/g5PDELc7JL4r2YLm8N87DOoMIdTfky5kQuY3OVmQzxbMf9AoGBAJXo" + |
| 83 | + "MBuBEbyWxfs389waPhSc4uw658iEPlg8WZZXMaHSsqCxdmpBsE9qw42UC57ObY7m" + |
| 84 | + "jV2vFAEn5Ek5GhPqnbM8aWHyd6QFP6946pp4SeUZNqxcu3F83y2js6HXHaD9bEf3" + |
| 85 | + "j/Bb1jrGr70Le9KgDaE9e1Q7xz1TY7byzUdbThvrAoGBAIsjuzdd376LUb6ClFVL" + |
| 86 | + "KMMCNFq9z2c8YHYITmKTRUVGtrKcS1daHByBiLA24jI43xzNVcjwaaI+OUmjH5XY" + |
| 87 | + "qhWo2p2F+diSkZSK7wBq9wFQaURXDv36QzdVbspMQluruMmUa5FCnApNoX4hPIUS" + |
| 88 | + "oby0A0cpHjPY+bR+KlAPivKg"; |
| 89 | + |
| 90 | + // Sample IdP certificate for tests (private key above) |
| 91 | + final static private String SAMPLE_CERTIFICATE = |
| 92 | + "MIIClzCCAX8CBgGXq4B+JzANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTI1MDYyNjA5" + |
| 93 | + "MDgzNFoXDTM1MDYyNjA5MTAxNFowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEP" + |
| 94 | + "ADCCAQoCggEBAMsWsdoc10kDJXdNy1pqbQxf3vwIoQMoaXF65GZKeMxUN9OWMjvNOFajBsniyqDogK8O" + |
| 95 | + "1OV8ACEg/Gmyk9gchPIDv09Xnv9j+TcLPnkMymbX1jAlRsRwdKfZnp8hw6Zdqc+I1QHcDVnH8nFkdg/O" + |
| 96 | + "AhpYYTBFMEQKNIiVnBgzWI5W1loys5KpznPZx9FU18FhDn+YOfdkQa/6fL5q5UQCCaGaVobGBfuYtOhE" + |
| 97 | + "HO/S31M18zySixpgEb5BqcKH3a+yzAQABJQO3POSaI2DW1DRzyB7DbaE4kmeH+iG2f6Y0daRLlQhLMGW" + |
| 98 | + "llaX3/qiW/vWzW3K9tmF+HJenTEU3qqwrvMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAasPZXVq0qJn4" + |
| 99 | + "mt1h0wZSlpyPm54hV2MNlxE7bd1I2fRtxmBTTUuOLEi6ca5iStKoHO6N7kCGlSWLs49/dqeNKjHweCV7" + |
| 100 | + "daK7t8tp8sKNPEcDoz0jFiVXVeUTLm0VjrdbdCSpamm9/Z/4EMaxE5SQKTHEHu83uqr1biNjr6n82WSj" + |
| 101 | + "xWMdh/hDQbtS08rItYSrkxJ3PLIdhxqJ/uUTZ3EqE3Ulcc+coiIzeyVdO/r4sZJvG2XLyidGsxQBcCef" + |
| 102 | + "5AlNWd7EQUc1lioS1HNSqTiXwxKvfAd06bGCN3mz8z6XgpFNTRDtHsmK3L7h5EYNa3DBKg/mgxfE3DJt" + |
| 103 | + "XXCXJHBCzQ=="; |
| 104 | + |
| 105 | + @Getter @Setter |
| 106 | + private URI baseUri; |
| 107 | + @Getter @Setter |
| 108 | + private String clientId; |
| 109 | + @Getter @Setter |
| 110 | + private String clientSecret; |
| 111 | + @Getter @Setter |
| 112 | + private URI redirectUri; |
| 113 | + private PrivateKey privKey; |
| 114 | + private Certificate certificate; |
| 115 | + |
| 116 | + private Map<String, User> authorizationCodes = new HashMap<>(); |
| 117 | + |
| 118 | + private HttpServer server = null; |
| 119 | + |
| 120 | + public FakeOidcIdpServer(URI baseUri) throws GeneralSecurityException { |
| 121 | + this.baseUri = baseUri; |
| 122 | + this.loadSampleCert(); |
| 123 | + } |
| 124 | + |
| 125 | + public FakeOidcIdpServer() throws GeneralSecurityException { |
| 126 | + this(DEFAULT_BASE_URI); |
| 127 | + } |
| 128 | + |
| 129 | + public void registerClient(String clientId, String clientSecret, URI redirectUri) { |
| 130 | + this.clientId = clientId; |
| 131 | + this.clientSecret = clientSecret; |
| 132 | + this.redirectUri = redirectUri; |
| 133 | + } |
| 134 | + |
| 135 | + public void addAuthorizatioCode(String code, String username, String role) { |
| 136 | + this.authorizationCodes.put(code, new User(username, role)); |
| 137 | + } |
| 138 | + |
| 139 | + private void loadSampleCert() throws NoSuchAlgorithmException, InvalidKeySpecException, CertificateException { |
| 140 | + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); |
| 141 | + InputStream is = new ByteArrayInputStream(Base64.getDecoder().decode(SAMPLE_CERTIFICATE)); |
| 142 | + this.certificate = certFactory.generateCertificate(is); |
| 143 | + PKCS8EncodedKeySpec privSpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(SAMPLE_PRIVATE_KEY)); |
| 144 | + KeyFactory kf = KeyFactory.getInstance("RSA"); |
| 145 | + this.privKey = kf.generatePrivate(privSpec); |
| 146 | + } |
| 147 | + |
| 148 | + private void checkBasicAuth(Request request) throws ForbiddenException { |
| 149 | + final String basicPrefix = "Basic "; |
| 150 | + String authHeader = request.getHeader("Authorization"); |
| 151 | + if (authHeader == null) { |
| 152 | + throw new ForbiddenException("Authorization header not present"); |
| 153 | + } |
| 154 | + if (!authHeader.startsWith(basicPrefix)) { |
| 155 | + throw new ForbiddenException("Authorization header is not basic auth"); |
| 156 | + } |
| 157 | + String encodedCredentials = authHeader.substring(basicPrefix.length()); |
| 158 | + String decodedCredentials = new String(Base64.getDecoder().decode(encodedCredentials), |
| 159 | + StandardCharsets.UTF_8); |
| 160 | + String expectedCredentials = "%s:%s".formatted(this.clientId, this.clientSecret); |
| 161 | + if (!expectedCredentials.equals(decodedCredentials)) { |
| 162 | + throw new ForbiddenException("Invalid credentials"); |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + private String signEncodeToken(ObjectNode token) throws SignatureException, InvalidKeyException, NoSuchAlgorithmException { |
| 167 | + ObjectNode header = JsonNodeFactory.instance.objectNode() |
| 168 | + .put("alg", "RS256") |
| 169 | + .put("typ", "JWT") |
| 170 | + .put("kid", SAMPLE_KID); |
| 171 | + String jwt = encodeJson(header) + "." + encodeJson(token); |
| 172 | + Signature signature = Signature.getInstance("SHA256withRSA"); |
| 173 | + signature.initSign(privKey); |
| 174 | + signature.update(jwt.getBytes(StandardCharsets.UTF_8)); |
| 175 | + byte[] signBytes = signature.sign(); |
| 176 | + jwt = jwt + "." + Base64.getUrlEncoder().encodeToString(signBytes); |
| 177 | + return jwt; |
| 178 | + } |
| 179 | + |
| 180 | + private String encodeJson(ObjectNode token) { |
| 181 | + return Base64.getUrlEncoder().encodeToString( |
| 182 | + token.toString().getBytes(StandardCharsets.UTF_8)); |
| 183 | + } |
| 184 | + |
| 185 | + private ObjectNode generateIdToken(User user) { |
| 186 | + ObjectNode token = JsonNodeFactory.instance.objectNode() |
| 187 | + .put("exp", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond()) |
| 188 | + .put("iat", Instant.now().getEpochSecond()) |
| 189 | + .put("auth_time", Instant.now().getEpochSecond()) |
| 190 | + .put("jti", "f9e39317-81e0-4334-a75e-994003f1d160") |
| 191 | + .put("iss", this.baseUri.toString()) |
| 192 | + .put("typ", "ID") |
| 193 | + .put("azp", this.clientId) |
| 194 | + .put("sid", "5eac7f33-3818-4438-b20a-78dfaec74abd") |
| 195 | + .put("scope", "openid") |
| 196 | + .put("acr", "1") |
| 197 | + .put("aud", this.clientId) |
| 198 | + .put("sub", "8b17b6a1-3c91-491b-9f9f-d7c314f08271") |
| 199 | + .put("preferred_username", user.username) |
| 200 | + .put("role", user.role); |
| 201 | + return token; |
| 202 | + } |
| 203 | + |
| 204 | + private ObjectNode generateAccessToken(User user) { |
| 205 | + ObjectNode token = JsonNodeFactory.instance.objectNode() |
| 206 | + .put("exp", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond()) |
| 207 | + .put("iat", Instant.now().getEpochSecond()) |
| 208 | + .put("jti", "trrtna:df789356-42fe-4bf3-8607-303161e9fe0e") |
| 209 | + .put("iss", this.baseUri.toString()) |
| 210 | + .put("typ", "Bearer") |
| 211 | + .put("azp", this.clientId) |
| 212 | + .put("sid", "c83ec19e-fc41-4764-9fe6-2b92b46fa10e") |
| 213 | + .put("acr", "1") |
| 214 | + .put("preferred_username", user.username); |
| 215 | + return token; |
| 216 | + } |
| 217 | + |
| 218 | + public void start() throws IOException { |
| 219 | + this.server = HttpServer.createSimpleServer(null, |
| 220 | + this.baseUri.getHost(), this.baseUri.getPort()); |
| 221 | + this.server.getServerConfiguration().addHttpHandler(new HttpHandler() { |
| 222 | + @Override |
| 223 | + public void service(Request request, Response response) throws Exception { |
| 224 | + ObjectNode data = JsonNodeFactory.instance.objectNode() |
| 225 | + .put("issuer", baseUri.toString()) |
| 226 | + .put("authorization_endpoint", baseUri.resolve(AUTH_PATH).toString()) |
| 227 | + .put("token_endpoint", baseUri.resolve(TOKEN_PATH).toString()) |
| 228 | + .put("jwks_uri", baseUri.resolve(JWKS_PATH).toString()); |
| 229 | + data.putArray("grant_types_supported") |
| 230 | + .add("authorization_code"); |
| 231 | + data.putArray("response_types_supported") |
| 232 | + .add("code") |
| 233 | + .add("none") |
| 234 | + .add("id_token") |
| 235 | + .add("token"); |
| 236 | + data.putArray("claims_supported") |
| 237 | + .add("aud") |
| 238 | + .add("sub") |
| 239 | + .add("iss") |
| 240 | + .add("auth_time") |
| 241 | + .add("preferred_username") |
| 242 | + .add("email"); |
| 243 | + data.putArray("subject_types_supported") |
| 244 | + .add("public"); |
| 245 | + data.putArray("id_token_signing_alg_values_supported") |
| 246 | + .add("RS256"); |
| 247 | + data.putArray("token_endpoint_auth_methods_supported") |
| 248 | + .add("client_secret_basic"); |
| 249 | + String content = data.toPrettyString(); |
| 250 | + response.setContentType(MediaType.APPLICATION_JSON); |
| 251 | + response.setContentLength(content.length()); |
| 252 | + response.getWriter().write(content); |
| 253 | + } |
| 254 | + }, DISCOVERY_PATH); |
| 255 | + this.server.getServerConfiguration().addHttpHandler(new HttpHandler() { |
| 256 | + @Override |
| 257 | + public void service(Request request, Response response) throws Exception { |
| 258 | + try { |
| 259 | + checkBasicAuth(request); |
| 260 | + if (!MediaType.APPLICATION_FORM_URLENCODED_TYPE.withCharset(StandardCharsets.UTF_8.name()) |
| 261 | + .equals(MediaType.valueOf(request.getContentType()))) { |
| 262 | + throw new Exception("Received token request content type is not form-urlencoded"); |
| 263 | + } |
| 264 | + if (!"authorization_code".equals(request.getParameter("grant_type"))) { |
| 265 | + throw new Exception("The received grant_type is not correct"); |
| 266 | + } |
| 267 | + if (!redirectUri.toString().equals(request.getParameter("redirect_uri"))) { |
| 268 | + throw new Exception("The received redirect_uri is not correct"); |
| 269 | + } |
| 270 | + String code = request.getParameter("code"); |
| 271 | + if (code == null) { |
| 272 | + throw new ForbiddenException("Missing code parameter"); |
| 273 | + } |
| 274 | + User user = authorizationCodes.remove(code); |
| 275 | + if (user == null) { |
| 276 | + throw new ForbiddenException("Invalid authorization code"); |
| 277 | + } |
| 278 | + ObjectNode data = JsonNodeFactory.instance.objectNode() |
| 279 | + .put("token_type", "Bearer") |
| 280 | + .put("refresh_token", "...") |
| 281 | + .put("access_token", signEncodeToken(generateAccessToken(user))) |
| 282 | + .put("id_token", signEncodeToken(generateIdToken(user))) |
| 283 | + .put("expires_in", 3600); |
| 284 | + String content = data.toPrettyString(); |
| 285 | + response.setContentType(MediaType.APPLICATION_JSON); |
| 286 | + response.setContentLength(content.length()); |
| 287 | + response.getWriter().write(content); |
| 288 | + } |
| 289 | + catch (ForbiddenException e) { |
| 290 | + response.setStatus( |
| 291 | + jakarta.ws.rs.core.Response.Status.UNAUTHORIZED.getStatusCode(), "Unauthorized"); |
| 292 | + return; |
| 293 | + } |
| 294 | + catch (Exception e) { |
| 295 | + response.setStatus( |
| 296 | + jakarta.ws.rs.core.Response.Status.BAD_REQUEST.getStatusCode(), "Bad request"); |
| 297 | + return; |
| 298 | + } |
| 299 | + } |
| 300 | + }, TOKEN_PATH); |
| 301 | + this.server.getServerConfiguration().addHttpHandler(new HttpHandler() { |
| 302 | + @Override |
| 303 | + public void service(Request request, Response response) throws Exception { |
| 304 | + ObjectNode data = JsonNodeFactory.instance.objectNode(); |
| 305 | + BigInteger certModulus = ((RSAPublicKey) certificate.getPublicKey()).getModulus(); |
| 306 | + BigInteger certExponent = ((RSAPublicKey) certificate.getPublicKey()).getPublicExponent(); |
| 307 | + ObjectNode key = JsonNodeFactory.instance.objectNode() |
| 308 | + .put("kid", SAMPLE_KID) |
| 309 | + .put("kty", "RSA") |
| 310 | + .put("alg", "RS256") |
| 311 | + .put("n", Base64.getUrlEncoder().encodeToString(certModulus.toByteArray())) |
| 312 | + .put("e", Base64.getUrlEncoder().encodeToString(certExponent.toByteArray())); |
| 313 | + key.putArray("x5c").add(Base64.getEncoder().encodeToString(certificate.getEncoded())); |
| 314 | + data.putArray("keys").add(key); |
| 315 | + String content = data.toPrettyString(); |
| 316 | + response.setContentType(MediaType.APPLICATION_JSON); |
| 317 | + response.setContentLength(content.length()); |
| 318 | + response.getWriter().write(content); |
| 319 | + } |
| 320 | + }, JWKS_PATH); |
| 321 | + this.server.start(); |
| 322 | + } |
| 323 | + |
| 324 | + public void shutdown() { |
| 325 | + if (this.server != null) { |
| 326 | + this.server.shutdown(); |
| 327 | + } |
| 328 | + } |
| 329 | + |
| 330 | +} |
0 commit comments