Last active
November 1, 2024 13:01
-
-
Save thomasdarimont/df589bdf06d4ad4ccef5430e8a717988 to your computer and use it in GitHub Desktop.
Spring Boot 3.3.5 JwtClientAuthApp example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package demo.jwtclientauth; | |
import com.fasterxml.jackson.core.JsonProcessingException; | |
import com.fasterxml.jackson.databind.ObjectMapper; | |
import com.nimbusds.jose.JOSEException; | |
import com.nimbusds.jose.JOSEObjectType; | |
import com.nimbusds.jose.JWSAlgorithm; | |
import com.nimbusds.jose.JWSHeader; | |
import com.nimbusds.jose.JWSObject; | |
import com.nimbusds.jose.Payload; | |
import com.nimbusds.jose.crypto.MACSigner; | |
import com.nimbusds.jose.crypto.RSASSASigner; | |
import com.nimbusds.jose.jwk.RSAKey; | |
import com.nimbusds.jose.util.Base64URL; | |
import com.nimbusds.jose.util.X509CertUtils; | |
import lombok.extern.slf4j.Slf4j; | |
import org.apache.tomcat.util.codec.binary.Base64; | |
import org.springframework.boot.CommandLineRunner; | |
import org.springframework.boot.WebApplicationType; | |
import org.springframework.boot.autoconfigure.SpringBootApplication; | |
import org.springframework.boot.builder.SpringApplicationBuilder; | |
import org.springframework.context.annotation.Bean; | |
import org.springframework.http.HttpEntity; | |
import org.springframework.http.HttpHeaders; | |
import org.springframework.http.MediaType; | |
import org.springframework.util.LinkedMultiValueMap; | |
import org.springframework.web.client.RestTemplate; | |
import java.io.IOException; | |
import java.nio.charset.Charset; | |
import java.nio.file.Files; | |
import java.nio.file.Path; | |
import java.security.KeyFactory; | |
import java.security.NoSuchAlgorithmException; | |
import java.security.cert.X509Certificate; | |
import java.security.interfaces.RSAPrivateKey; | |
import java.security.spec.InvalidKeySpecException; | |
import java.security.spec.PKCS8EncodedKeySpec; | |
import java.time.Duration; | |
import java.time.Instant; | |
import java.util.Map; | |
import java.util.UUID; | |
import java.util.function.Function; | |
@Slf4j | |
@SpringBootApplication | |
public class JwtClientAuthApp { | |
public static void main(String[] args) { | |
new SpringApplicationBuilder(JwtClientAuthApp.class).web(WebApplicationType.NONE).run(args); | |
} | |
@Bean | |
CommandLineRunner cli() { | |
return args -> { | |
log.info("Jwt Client Authentication"); | |
var clientId = "acme-service-client-jwt-auth"; | |
var issuer = "https://id.acme.test:8443/auth/realms/acme-internal"; | |
var issuedAt = Instant.now(); | |
var tokenLifeTime = Duration.ofHours(24); | |
var clientJwtPayload = Map.<String, Object>ofEntries( // | |
Map.entry("iss", clientId), // | |
Map.entry("sub", clientId), // | |
Map.entry("aud", issuer), // | |
Map.entry("iat", issuedAt.getEpochSecond()), // | |
Map.entry("exp", issuedAt.plus(tokenLifeTime).getEpochSecond()), // | |
Map.entry("jti", UUID.randomUUID().toString()) // | |
); | |
// { // Signed JWT example | |
// // generate Signed JWT | |
// var clientJwtToken = generateTokenSignedWithPrivateKey(clientJwtPayload); | |
// log.info("Client JWT Token: {}", clientJwtToken); | |
// | |
// // use clientjwt to request token for service | |
// var accessTokenResponse = requestToken(issuer, clientJwtToken); | |
// log.info("AccessToken: {}", accessTokenResponse.get("access_token")); | |
// } | |
{ // Signed JWT with Client Secret example | |
// generate Signed JWT with client secret | |
String clientSecret = "8FKyMMDOiBp2CIdu4TtssY6HRP5nHRsI"; | |
var clientJwtToken = generateTokenSignedWithClientSecret(clientJwtPayload, clientSecret); | |
log.info("Client JWT Token: {}", clientJwtToken); | |
// use Signed JWT with client secret to request token for service | |
// var accessTokenResponse = requestToken(issuer, clientJwtToken); | |
// log.info("AccessToken: {}", accessTokenResponse.get("access_token")); | |
// use clientjwt perform PAR request | |
var requestUri = requestPAR(issuer, clientId, UUID.randomUUID().toString(), "https://www.keycloak.org/app/", "openid profile", clientJwtToken); | |
log.info("RequestUri: {}", requestUri); | |
} | |
}; | |
} | |
private Map<String, Object> requestToken(String issuer, String clientJwtToken) { | |
var rt = new RestTemplate(); | |
var headers = new HttpHeaders(); | |
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); | |
var requestBody = new LinkedMultiValueMap<String, String>(); | |
requestBody.add("grant_type", "client_credentials"); | |
requestBody.add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); | |
requestBody.add("client_assertion", clientJwtToken); | |
var tokenUrl = issuer + "/protocol/openid-connect/token"; | |
var responseEntity = rt.postForEntity(tokenUrl, new HttpEntity<>(requestBody, headers), Map.class); | |
var accessTokenResponse = responseEntity.getBody(); | |
return accessTokenResponse; | |
} | |
private String requestPAR(String issuer, String clientId, String nonce, String redirectUri, String scope, String clientJwtToken) { | |
var rt = new RestTemplate(); | |
var headers = new HttpHeaders(); | |
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); | |
var requestBody = new LinkedMultiValueMap<String, String>(); | |
requestBody.add("response_type", "code"); | |
requestBody.add("client_id", clientId); | |
requestBody.add("nonce", nonce); | |
requestBody.add("redirect_uri", redirectUri); | |
requestBody.add("scope", scope); | |
requestBody.add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); | |
requestBody.add("client_assertion", clientJwtToken); | |
var tokenUrl = issuer + "/protocol/openid-connect/ext/par/request"; | |
var responseEntity = rt.postForEntity(tokenUrl, new HttpEntity<>(requestBody, headers), Map.class); | |
var parResponse = responseEntity.getBody(); | |
return String.valueOf(parResponse.get("request_uri")); | |
} | |
private String generateTokenSignedWithPrivateKey(Map<String, Object> clientJwtPayload) { | |
try { | |
log.info("Payload: {}", new ObjectMapper().writeValueAsString(clientJwtPayload)); | |
var cert = parseCertificate("apps/jwt-client-authentication/client_cert.pem"); | |
var privateKey = readPrivateKeyFile("apps/jwt-client-authentication/client_key.pem"); | |
var base64URL = createKeyThumbprint(cert, "SHA-1"); | |
var jwsObject = new JWSObject(new JWSHeader | |
.Builder(JWSAlgorithm.RS256) | |
.type(JOSEObjectType.JWT) | |
.x509CertThumbprint(base64URL) // SHA-1 | |
.build(), new Payload(clientJwtPayload)); | |
var signer = new RSASSASigner(privateKey); | |
jwsObject.sign(signer); | |
var clientJwtToken = jwsObject.serialize(); | |
return clientJwtToken; | |
} catch (Exception e) { | |
throw new RuntimeException(e); | |
} | |
} | |
private String generateTokenSignedWithClientSecret(Map<String, Object> clientJwtPayload, String clientSecret) { | |
var cert = parseCertificate("apps/jwt-client-authentication/client_cert.pem"); | |
var base64URL = createKeyThumbprint(cert, "SHA-1"); | |
var jwsObject = new JWSObject(new JWSHeader | |
.Builder(JWSAlgorithm.HS256) | |
.type(JOSEObjectType.JWT) | |
.x509CertThumbprint(base64URL) // SHA-1 | |
.build(), new Payload(clientJwtPayload)); | |
try { | |
var signer = new MACSigner(clientSecret); | |
jwsObject.sign(signer); | |
} catch (JOSEException e) { | |
throw new RuntimeException(e); | |
} | |
var clientJwtToken = jwsObject.serialize(); | |
return clientJwtToken; | |
} | |
private String generateClientSignedJwtToken(String clientId, String issuer, Instant issuedAt, Duration tokenLifeTime, Function<Map<String, Object>, String> jwtGenerator) throws JsonProcessingException, JOSEException { | |
var clientJwtPayload = Map.<String, Object>ofEntries( // | |
Map.entry("iss", clientId), // | |
Map.entry("sub", clientId), // | |
Map.entry("aud", issuer), // | |
Map.entry("iat", issuedAt.getEpochSecond()), // | |
Map.entry("exp", issuedAt.plus(tokenLifeTime).getEpochSecond()), // | |
Map.entry("jti", UUID.randomUUID().toString()) // | |
); | |
return jwtGenerator.apply(clientJwtPayload); | |
} | |
private X509Certificate parseCertificate(String path) { | |
try { | |
var cert = X509CertUtils.parse(Files.readString(Path.of(path), Charset.defaultCharset())); | |
return cert; | |
} catch (IOException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
private static Base64URL createKeyThumbprint(X509Certificate cert, String hashAlgorithm) { | |
try { | |
RSAKey rsaKey = RSAKey.parse(cert); | |
return rsaKey.computeThumbprint(hashAlgorithm); | |
} catch (JOSEException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
static RSAPrivateKey readPrivateKeyFile(String path) { | |
try { | |
var key = Files.readString(Path.of(path), Charset.defaultCharset()); | |
var privateKeyPEM = key // | |
.replace("-----BEGIN PRIVATE KEY-----", "") // | |
.replaceAll(System.lineSeparator(), "") // | |
.replace("-----END PRIVATE KEY-----", ""); | |
var encodedBytes = Base64.decodeBase64(privateKeyPEM); | |
var keySpec = new PKCS8EncodedKeySpec(encodedBytes); | |
return (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(keySpec); | |
} catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
<modelVersion>4.0.0</modelVersion> | |
<parent> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-parent</artifactId> | |
<version>3.3.5</version> | |
<relativePath/> <!-- lookup parent from repository --> | |
</parent> | |
<groupId>com.github.thomasdarimont.keycloak</groupId> | |
<artifactId>jwt-client-authentication</artifactId> | |
<version>0.0.1-SNAPSHOT</version> | |
<name>jwt-client-authentication</name> | |
<description>jwt-client-authentication</description> | |
<properties> | |
<java.version>17</java.version> | |
<lombok.version>1.18.30</lombok.version> | |
</properties> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-web</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-devtools</artifactId> | |
<scope>runtime</scope> | |
<optional>true</optional> | |
</dependency> | |
<dependency> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
<optional>true</optional> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-test</artifactId> | |
<scope>test</scope> | |
</dependency> | |
</dependencies> | |
<build> | |
<plugins> | |
<plugin> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-maven-plugin</artifactId> | |
<configuration> | |
<excludes> | |
<exclude> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
</exclude> | |
</excludes> | |
</configuration> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment