Created
January 9, 2022 20:37
-
-
Save Robinyo/cc90c191be74ca173fb483199b3efceb to your computer and use it in GitHub Desktop.
Private Key JWT Client Authentication
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
spring.profiles.active=@spring.profiles.active@ | |
spring.main.banner-mode=off | |
# Logging | |
logging.level.root=INFO | |
logging.level.reactor.netty.http.client.HttpClient=INFO |
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 au.gov.dta.rp.controller; | |
import au.gov.dta.rp.model.TokenResponse; | |
import au.gov.dta.rp.service.AuthNService; | |
import com.nimbusds.jwt.JWT; | |
import com.nimbusds.jwt.JWTClaimsSet; | |
import com.nimbusds.jwt.JWTParser; | |
import com.nimbusds.jwt.SignedJWT; | |
import lombok.AllArgsConstructor; | |
import lombok.extern.slf4j.Slf4j; | |
import org.springframework.stereotype.Controller; | |
import org.springframework.web.bind.annotation.GetMapping; | |
import org.springframework.web.bind.annotation.PostMapping; | |
import org.springframework.web.bind.annotation.RequestMapping; | |
import org.springframework.web.bind.annotation.RequestParam; | |
import org.springframework.web.servlet.mvc.support.RedirectAttributes; | |
import org.springframework.web.servlet.view.RedirectView; | |
import javax.servlet.http.Cookie; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletResponse; | |
import java.util.UUID; | |
@AllArgsConstructor | |
@Controller | |
@RequestMapping("/") | |
@Slf4j | |
public class AuthNController { | |
private final AuthNService authNService; | |
@PostMapping("/sign-in") | |
public RedirectView signIn(HttpServletResponse response) { | |
log.info("AuthController POST /sign-in"); | |
RedirectView redirectView = new RedirectView(); | |
String state = UUID.randomUUID().toString(); | |
Cookie authState = new Cookie("state", state); | |
authState.setMaxAge(3600); | |
response.addCookie(authState); | |
try { | |
redirectView.setUrl(authNService.getUrl(state)); | |
} catch (Exception e) { | |
log.error("{}", e.getLocalizedMessage()); | |
} | |
return redirectView; | |
} | |
@GetMapping("/authorization-code/callback") | |
public RedirectView authorizationCodeCallback(HttpServletRequest request, | |
@RequestParam(required = false) String code, | |
@RequestParam(required = false) String state, | |
@RequestParam(required = false) String error, | |
HttpServletResponse response, | |
RedirectAttributes redirectAttributes) { | |
log.info("AuthController GET /authorization-code/callback"); | |
if (error != null) { | |
log.error(error); | |
redirectAttributes.addFlashAttribute("error_message", error); | |
return new RedirectView("/request-tokens-error-response", true); | |
} | |
log.info("code={}", code); | |
log.info("state={}", state); | |
Cookie[] cookies = request.getCookies(); | |
String authState = ""; | |
for (Cookie cookie : cookies) { | |
if (cookie.getName().equalsIgnoreCase("state")) { | |
log.info("authState = cookie.getValue()"); | |
authState = cookie.getValue(); | |
} | |
} | |
if (!authState.equals(state)) { | |
String errorMessage = "'authState' is not equal to 'state'"; | |
log.error(errorMessage); | |
redirectAttributes.addFlashAttribute("error_message", errorMessage); | |
return new RedirectView("/request-tokens-error-response", true); | |
} | |
String url = "/"; | |
try { | |
TokenResponse tokenResponse = authNService.tokenRequest(code, state); | |
Cookie accessToken = new Cookie("access_token", tokenResponse.getAccess_token()); | |
accessToken.setMaxAge(3600); | |
response.addCookie(accessToken); | |
JWT jwt = JWTParser.parse(tokenResponse.getId_token()); | |
if (jwt instanceof SignedJWT) { | |
SignedJWT signedJWT = (SignedJWT) jwt; | |
JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet(); | |
redirectAttributes.addFlashAttribute("given_name", jwtClaimsSet.getClaim("given_name")); | |
redirectAttributes.addFlashAttribute("family_name", jwtClaimsSet.getClaim("family_name")); | |
redirectAttributes.addFlashAttribute("birthdate", jwtClaimsSet.getClaim("birthdate")); | |
redirectAttributes.addFlashAttribute("iss", jwtClaimsSet.getClaim("iss")); | |
redirectAttributes.addFlashAttribute("acr", jwtClaimsSet.getClaim("acr")); | |
url = "/request-tokens-success-response"; | |
} else { | |
url = "/request-tokens-error-response"; | |
} | |
} catch (Exception e) { | |
url = "/request-tokens-error-response"; | |
log.error("{}", e.getLocalizedMessage()); | |
redirectAttributes.addFlashAttribute("error_message", e.getLocalizedMessage()); | |
} | |
return new RedirectView(url, true); | |
} | |
} |
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 au.gov.dta.rp.service; | |
import au.gov.dta.rp.model.TokenResponse; | |
import au.gov.dta.rp.util.AuthConstants; | |
import au.gov.dta.rp.util.KeyStoreConstants; | |
import com.nimbusds.jose.JOSEException; | |
import com.nimbusds.jose.JWSAlgorithm; | |
import com.nimbusds.jose.JWSHeader; | |
import com.nimbusds.jose.JWSSigner; | |
import com.nimbusds.jose.crypto.RSASSASigner; | |
import com.nimbusds.jwt.JWTClaimsSet; | |
import com.nimbusds.jwt.SignedJWT; | |
import lombok.AllArgsConstructor; | |
import lombok.extern.slf4j.Slf4j; | |
import org.springframework.core.env.Environment; | |
import org.springframework.core.io.ClassPathResource; | |
import org.springframework.stereotype.Service; | |
import org.springframework.util.Assert; | |
import org.springframework.web.reactive.function.client.WebClient; | |
import org.springframework.web.util.UriComponents; | |
import org.springframework.web.util.UriComponentsBuilder; | |
import java.io.IOException; | |
import java.security.*; | |
import java.security.cert.CertificateException; | |
import java.util.Date; | |
import java.util.UUID; | |
import static org.springframework.web.reactive.function.BodyInserters.fromFormData; | |
@Service | |
@AllArgsConstructor | |
@Slf4j | |
public class AuthNService { | |
private final Environment env; | |
private final WebClient webClient; | |
public String getUrl(String state) { | |
log.info("AuthService -> getUrl()"); | |
String clientId = env.getProperty("relying.party.client-id"); | |
Assert.notNull(clientId, "clientId environment variable not found"); | |
String nonce = UUID.randomUUID().toString(); | |
UriComponents builder = UriComponentsBuilder | |
.fromHttpUrl(AuthConstants.BASE_URL) | |
.path(AuthConstants.SIGN_IN_PATH) | |
.queryParam("response_type", AuthConstants.RESPONSE_TYPE) | |
.queryParam("scope", AuthConstants.SCOPE) | |
.queryParam("state", state) | |
.queryParam("redirect_uri", AuthConstants.REDIRECT_URI) | |
.queryParam("nonce", nonce) | |
.queryParam("client_id", clientId) | |
.queryParam("acr_values", AuthConstants.ACR_VALUES) | |
.build() | |
.encode(); | |
log.info("url = {}", builder.toString()); | |
return builder.toString(); | |
} | |
public TokenResponse tokenRequest(String code, String state) throws KeyStoreException, IOException, CertificateException, | |
NoSuchAlgorithmException, UnrecoverableKeyException, JOSEException { | |
log.info("AuthService -> tokenRequest()"); | |
String clientId = env.getProperty("relying.party.client-id"); | |
Assert.notNull(clientId, "clientId environment variable not found"); | |
JWSSigner signer = null; | |
// Specify the key store type, e.g. Java KeyStore | |
KeyStore keyStore = KeyStore.getInstance(KeyStoreConstants.KEY_STORE_TYPE); | |
// You need a password to unlock the key store | |
char[] password = KeyStoreConstants.KEY_STORE_PASSWORD.toCharArray(); | |
// Load the key store file (an unfiltered file in the /resources directory, see: pom.xml) | |
keyStore.load(new ClassPathResource(KeyStoreConstants.KEY_STORE_FILE).getInputStream(), password); | |
// Retrieve the private key | |
Key key = keyStore.getKey(KeyStoreConstants.KEY_STORE_ALIAS, password); | |
if (key instanceof PrivateKey) { | |
log.info("key instanceof PrivateKey"); | |
signer = new RSASSASigner((PrivateKey) key); | |
} | |
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() | |
.issuer(clientId) | |
.subject(clientId) | |
.audience(AuthConstants.BASE_URL + AuthConstants.TOKEN_REQUEST_PATH) | |
.issueTime(new Date(new Date().getTime())) | |
.expirationTime(new Date(new Date().getTime() + AuthConstants.ONE_HOUR)) | |
.jwtID(UUID.randomUUID().toString()) | |
.build(); | |
SignedJWT signedJWT = new SignedJWT( | |
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KeyStoreConstants.KEY_STORE_ALIAS).build(), | |
claimsSet); | |
signedJWT.sign(signer); | |
return webClient | |
.post() | |
.uri(uriBuilder -> uriBuilder | |
.path(AuthConstants.TOKEN_REQUEST_PATH) | |
.build()) | |
.body( | |
fromFormData("grant_type", AuthConstants.GRANT_TYPE) | |
.with("code", code) | |
.with("client_id", clientId) | |
.with("redirect_uri", AuthConstants.REDIRECT_URI) | |
.with("client_assertion_type", AuthConstants.CLIENT_ASSERTION_TYPE) | |
.with("client_assertion", signedJWT.serialize())) | |
.retrieve() | |
.bodyToMono(TokenResponse.class) | |
.block(); | |
} | |
} |
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 au.gov.dta.rp.util; | |
public class KeyStoreConstants { | |
public static final String KEY_STORE_ALIAS = "testkey"; | |
public static final String KEY_STORE_FILE = "keystore.jks"; | |
public static final String KEY_STORE_PASSWORD = "nomoresecrets"; | |
public static final String KEY_STORE_TYPE = "JKS"; | |
private KeyStoreConstants(){ | |
throw new AssertionError(); | |
} | |
} | |
/* | |
keytool -genkeypair \ | |
-alias testkey \ | |
-keyalg RSA \ | |
-keysize 2048 \ | |
-dname "CN=Rob Ferguson, OU=Digital Identity, O=DTA, L=Canberra, ST=ACT, C=AU" \ | |
-keypass nomoresecrets \ | |
-validity 100 \ | |
-storetype JKS \ | |
-keystore keystore.jks \ | |
-storepass nomoresecrets | |
keytool -list \ | |
-storetype JKS \ | |
-keystore keystore.jks \ | |
-storepass nomoresecrets | |
*/ |
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> | |
<groupId>au.gov.dta</groupId> | |
<artifactId>rp</artifactId> | |
<version>0.0.1-SNAPSHOT</version> | |
<packaging>jar</packaging> | |
<name>Relying Party - Full Client with User Delegation</name> | |
<description>Use Java and Spring Boot to build an example Relying Party</description> | |
<inceptionYear>2021</inceptionYear> | |
<contributors> | |
<contributor> | |
<name>Rob Ferguson</name> | |
<roles> | |
<role>Software Engineer</role> | |
</roles> | |
<timezone>+10</timezone> | |
<url>https://robferguson/blog</url> | |
<email>rob.ferguson (at) robferguson.org</email> | |
</contributor> | |
</contributors> | |
<properties> | |
<java.version>11</java.version> | |
<nimbus-jose-jwt.version>9.7</nimbus-jose-jwt.version> | |
<bcpkix-jdk15on.version>1.68</bcpkix-jdk15on.version> | |
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> | |
</properties> | |
<parent> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-parent</artifactId> | |
<version>2.4.4</version> | |
<relativePath/> <!-- lookup parent from repository --> | |
</parent> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-mustache</artifactId> | |
</dependency> | |
<!-- Adding both spring-boot-starter-web and spring-boot-starter-webflux modules in your application results in | |
Spring Boot auto-configuring Spring MVC, not WebFlux. --> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-web</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-webflux</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>com.nimbusds</groupId> | |
<artifactId>nimbus-jose-jwt</artifactId> | |
<version>${nimbus-jose-jwt.version}</version> | |
</dependency> | |
<!-- | |
Adds static JWKSet.load(KeyStore,PasswordLookup) method to load JWKs from a JCA key store. | |
Makes org.bouncycastle:bcpkix-jdk15on an optional dependency. | |
See: CHANGELOG.txt | |
<dependency> | |
<groupId>org.bouncycastle</groupId> | |
<artifactId>bcpkix-jdk15on</artifactId> | |
<version>${bcpkix-jdk15on.version}</version> | |
</dependency> | |
--> | |
<dependency> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
<optional>true</optional> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-devtools</artifactId> | |
<scope>runtime</scope> | |
<optional>true</optional> | |
</dependency> | |
<!-- Add Test dependencies --> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-test</artifactId> | |
<scope>test</scope> | |
</dependency> | |
</dependencies> | |
<profiles> | |
<profile> | |
<id>dev</id> | |
<activation> | |
<!-- See: application.properties - spring.profiles.active=@spring.profiles.active@ --> | |
<activeByDefault>true</activeByDefault> | |
</activation> | |
<properties> | |
<spring.profiles.active>dev</spring.profiles.active> | |
</properties> | |
</profile> | |
<profile> | |
<id>test</id> | |
<activation> | |
<activeByDefault>false</activeByDefault> | |
</activation> | |
<properties> | |
<spring.profiles.active>test</spring.profiles.active> | |
</properties> | |
</profile> | |
</profiles> | |
<build> | |
<resources> | |
<resource> | |
<directory>src/main/resources</directory> | |
<filtering>false</filtering> | |
</resource> | |
<resource> | |
<directory>src/main/resources/filtered</directory> | |
<filtering>true</filtering> | |
</resource> | |
</resources> | |
<plugins> | |
<!-- Package as an executable jar --> | |
<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> | |
<executions> | |
<execution> | |
<goals> | |
<goal>repackage</goal> | |
</goals> | |
</execution> | |
</executions> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://docs.spring.io/spring-security/reference/servlet/oauth2/client/client-authentication.html#_authenticate_using_private_key_jwt