Commit 4bbc84a6 authored by pawel.napieracz's avatar pawel.napieracz Committed by 陈健

OAUTH-3147 Add Id Token decryption code and expose JWKS for enc purposes

Added the code to support JWE. The decryption service, key generation
and storage was added. Some additional conditions was injected to code
to detect if Id Token is encrypted.
The JWKS endpoint was added for encryption purposes.
The documentation was updated to describe the encryption's classes.
parent 972e36b3
......@@ -22,8 +22,14 @@ The Web client must support the following scopes:
* profile
The Onegini Token Server only redirects to preconfigured endpoints after login or logout. You must configure the following endpoints in the Onegini Token Server:
* Redirect URL: `http://localhost:8080/`
* Redirect URL: `http://localhost:8080/login`
* Post Logout Redirect URL: `http://localhost:8080/signout-callback-oidc`
### Configuring id token encryption
Onegini Token Server support encryption to provide confidentiality of the claims. It can be configured by providing JWKS endpoint and choosing an encryption method
in OpenID Connect configuration:
* Encryption method: select one of encryption method that will be used to encrypt the Id Token.
* JWKS URI: endpoint that's return list of keys for encrypting purpose.
## Set up the application configuration
......@@ -70,6 +76,16 @@ In this example we use the `sub` and the `name` value, but you can use any value
its signature against the keys that are returned by the JWKS endpoint of the OP. It verifies that the claims are from the issuer, intended for the correct
audience and that they have not expired.
### OpenIdTokenDecrypterWrapper
[OpenIdTokenDecrypterWrapper.java](src/main/java/com/onegini/oidc/security/OpenIdTokenDecrypterWrapper.java) decrypts the ID token. The ID token has been
encrypted by freshly generated CEK (Content Encryption Key) that is encrypted by one of asymetric key. Public parts of those keys are share by JWKS endpoint
available on this example application.
See [WellKnownJwksController.java](src/main/java/com/onegini/oidc/WellKnownJwksController.java) for more information.
### EncryptionAlgorithms
The [EncryptionAlgorithms.java](src/main/java/com/onegini/oidc/model/EncryptionAlgorithms.java) contains all algorithms that could be used by OP to encrypt the
CEK (Content Encryption Key).
### UserInfo
The [UserInfo.java](src/main/java/com/onegini/oidc/model/UserInfo.java) is a POJO for user information. It is used as user principal in Spring
Security.
......@@ -89,4 +105,23 @@ the modelMap for the template that shows the user information, ID token and the
### LogoutController
Thie [LogoutController.java](src/main/java/com/onegini/oidc/LogoutController.java) contains the logic to end the session. The user first comes to
the `/logout` endpoint. If the user was logged in via an ID token, they are redirected to the end session endpoint of the OP. The OP ends the session of the
user and redirects it back to `http://localhost:8080/signout-callback-oidc`. Then the user is logged out in Spring Security and redirected to the home page.
\ No newline at end of file
user and redirects it back to `http://localhost:8080/signout-callback-oidc`. Then the user is logged out in Spring Security and redirected to the home page.
### WellKnownJwksController
The [WellKnownJwksController.java](src/main/java/com/onegini/oidc/WellKnownJwksController.java) is responsible to return a JWKS list (for encryption purpose).
It returns only that kind of keys that are supported on OP. However it's only an example and in production application there is strictly required to store keys
in persistence storage and make a key rotation. Please keep in mind that OP gets the first key that matched its criteria so returning obsolete key on before
fresh one is a mistake.
### JweKeyGenerator
The [JweKeyGenerator.java](src/main/java/com/onegini/oidc/encryption/JweKeyGenerator.java) is responsible for key generation. It shows how to generate the RSA
and EC keys.
### JwkSetProvider
The [JwkSetProvider.java](src/main/java/com/onegini/oidc/encryption/JwkSetProvider.java) has a storage role for caching a encryption keys for this example
application. In production environment it should be replaced by service cooperating with database.
### JweDecrypterService
The [JweDecrypterService.java](src/main/java/com/onegini/oidc/encryption/JweDecrypterService.java) is main place where encryption stuff goes. The `decrypt`
method consumes the encrypted JWT and tries to decrypt it by finding relevant key in Cache which is pass with encrypted JWT to external `nimbusds-jose-jwt`
library. The returned string is a Signed JWT which should be verified.
\ No newline at end of file
......@@ -47,18 +47,18 @@ public class LogoutController {
@GetMapping(PAGE_LOGOUT)
private String logout(final HttpServletRequest request, final HttpServletResponse response, final Principal principal) {
// Save idToken before authentication is cleared
final String idToken = getIdToken(principal);
final UserInfo userInfo = getUserInfo(principal);
endSessionInSpringSecurity(request, response);
if (StringUtils.hasLength(idToken)) {
LOG.info("Has idToken {}", idToken);
if (userInfo != null && StringUtils.hasLength(userInfo.getIdToken())) {
LOG.info("Has idToken {}", userInfo.getIdToken());
final Map configuration = restTemplate.getForObject(applicationProperties.getIssuer() + WELL_KNOWN_CONFIG_PATH, Map.class);
@SuppressWarnings("squid:S2583") final String endSessionEndpoint = configuration == null ? null : (String) configuration.get(KEY_END_SESSION_ENDPOINT);
if (StringUtils.hasLength(endSessionEndpoint)) {
return endOpenIdSession(idToken, endSessionEndpoint);
return endOpenIdSession(userInfo, endSessionEndpoint);
}
}
......@@ -71,13 +71,11 @@ public class LogoutController {
return REDIRECT_TO_INDEX;
}
private String getIdToken(final Principal principal) {
private UserInfo getUserInfo(final Principal principal) {
if (principal instanceof PreAuthenticatedAuthenticationToken) {
final PreAuthenticatedAuthenticationToken authenticationToken = (PreAuthenticatedAuthenticationToken) principal;
final UserInfo userInfo = (UserInfo) authenticationToken.getPrincipal();
return userInfo.getIdToken();
return (UserInfo) authenticationToken.getPrincipal();
}
return null;
}
......@@ -89,12 +87,15 @@ public class LogoutController {
}
}
private String endOpenIdSession(final String idToken, final String endSessionEndpoint) {
private String endOpenIdSession(final UserInfo userInfo, final String endSessionEndpoint) {
final MultiValueMap<String, String> requestParameters = new LinkedMultiValueMap<>();
final String postLogoutRedirectUri = ServletUriComponentsBuilder.fromCurrentContextPath().path(PAGE_SIGNOUT_CALLBACK_OIDC).build().toUriString();
requestParameters.add(PARAM_POST_LOGOUT_REDIRECT_URI, postLogoutRedirectUri);
requestParameters.add(PARAM_ID_TOKEN_HINT, idToken);
// Token Server doesn't know how to decode the token id and it doesn't store encoded token id so passing that won't help to detect which session should be logged out.
if (!userInfo.isEncryptionEnabled()) {
requestParameters.add(PARAM_ID_TOKEN_HINT, userInfo.getIdToken());
}
final String redirectUri = UriComponentsBuilder.fromUriString(endSessionEndpoint)
.queryParams(requestParameters)
......
package com.onegini.oidc;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import com.nimbusds.jose.JWEAlgorithm;
import com.onegini.oidc.config.ApplicationProperties;
import com.onegini.oidc.encryption.JwkSetProvider;
import com.onegini.oidc.model.EncryptionAlgorithms;
import net.minidev.json.JSONObject;
@RestController
public class WellKnownJwksController {
public static final String PAGE_WELL_KNOWN_JWKS = "/.well-known/jwks.json";
private static final String WELL_KNOWN_CONFIG_PATH = "/.well-known/openid-configuration";
@Resource
private ApplicationProperties applicationProperties;
@Resource
private RestTemplate restTemplate;
@Resource
private JwkSetProvider jwkSetProvider;
@GetMapping(PAGE_WELL_KNOWN_JWKS)
private JSONObject getJwks(final HttpServletRequest request, final HttpServletResponse response) {
final JWEAlgorithm encryptionAlgorithm = getMostAdequateSupportedAlgorithm();
//We return filtered list with the encryption keys (only public part). Token Server use first one that match its criteria.
return jwkSetProvider.getPublicJWKS(encryptionAlgorithm);
}
private JWEAlgorithm getMostAdequateSupportedAlgorithm() {
//We should get a list of supported encryption algorithms from Token Server and select one which is also proper for us
final Map configuration = restTemplate.getForObject(applicationProperties.getIssuer() + WELL_KNOWN_CONFIG_PATH, Map.class);
@SuppressWarnings("squid:S2583") final List<String> supportedEncryptionAlgorithms = (List<String>) configuration
.get("id_token_encryption_alg_values_supported");
final Optional<JWEAlgorithm> mostAdequateSupportedAlgorithm = EncryptionAlgorithms.ENCRYPTION_ALGORITHMS_PRIORITY.stream()
.filter(alg -> supportedEncryptionAlgorithms.contains(alg.getName()))
.findFirst();
return mostAdequateSupportedAlgorithm.orElseThrow(() -> new RuntimeException("Supported algorithm wasn't found in Token Server"));
}
}
\ No newline at end of file
package com.onegini.oidc.encryption;
import static com.nimbusds.oauth2.sdk.util.StringUtils.isBlank;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWEDecrypter;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.crypto.ECDHDecrypter;
import com.nimbusds.jose.crypto.RSADecrypter;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyType;
import com.nimbusds.jose.jwk.RSAKey;
@Service
public class JweDecrypterService {
@Resource
private JwkSetProvider jwkSetProvider;
public String decrypt(final JWEObject jweObject) throws JOSEException {
validateKeyIdExists(jweObject);
final JWK relevantKey = getRelevantKey(jweObject);
final JWEDecrypter decrypter = getDecrypter(relevantKey);
jweObject.decrypt(decrypter);
return jweObject.getPayload().toString();
}
private void validateKeyIdExists(final JWEObject jweObject) {
if (isBlank(jweObject.getHeader().getKeyID())) {
throw new IllegalArgumentException("JWE doesn't contains a key id");
}
}
private JWK getRelevantKey(final JWEObject jweObject) {
final JWKSet privateJWKS = jwkSetProvider.getPrivateJWKS();
final JWK relevantKey = privateJWKS.getKeyByKeyId(jweObject.getHeader().getKeyID());
if (relevantKey != null) {
return relevantKey;
} else {
throw new IllegalArgumentException("JWK set isn't contains a relevant JWK.");
}
}
private JWEDecrypter getDecrypter(final JWK jwk) {
final KeyType keyType = jwk.getKeyType();
try {
if (KeyType.RSA.equals(keyType)) {
return new RSADecrypter((RSAKey) jwk);
} else if (KeyType.EC.equals(keyType)) {
return new ECDHDecrypter((ECKey) jwk);
} else {
throw new IllegalStateException(String.format("Unsupported type of key (%s)", jwk.getKeyType()));
}
} catch (final JOSEException e) {
final String msg = String.format("Could not create the JWE decrypter for type (%s).", keyType);
throw new RuntimeException(msg, e);
}
}
}
package com.onegini.oidc.encryption;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.jwk.Curve;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.KeyType;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.onegini.oidc.WellKnownJwksController;
@Service
public class JweKeyGenerator {
private static final Logger LOG = LoggerFactory.getLogger(WellKnownJwksController.class);
private static final int RSA_KEYSIZE = 2048;
public JWK generateKey(final KeyType keyType, final JWEAlgorithm jweAlgorithm) {
if (KeyType.RSA.equals(keyType)) {
return generateRSAKey(keyType, jweAlgorithm);
} else if (KeyType.EC.equals(keyType)) {
return generateECKey(keyType, jweAlgorithm);
} else {
LOG.error("No supported KeyType ({})", keyType);
return null;
}
}
private JWK generateRSAKey(final KeyType keyType, final JWEAlgorithm jweAlgorithm) {
try {
final KeyPairGenerator gen = KeyPairGenerator.getInstance(keyType.getValue());
gen.initialize(RSA_KEYSIZE);
final KeyPair keyPair = gen.generateKeyPair();
final JWK jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey((RSAPrivateKey) keyPair.getPrivate())
.keyUse(KeyUse.ENCRYPTION)
.keyID(UUID.randomUUID().toString())
.algorithm(jweAlgorithm)
.build();
return jwk;
} catch (final NoSuchAlgorithmException e) {
LOG.error("Generating a RSA key failed.", e);
}
return null;
}
private JWK generateECKey(final KeyType keyType, final JWEAlgorithm jweAlgorithm) {
try {
final KeyPairGenerator gen = KeyPairGenerator.getInstance(keyType.getValue());
gen.initialize(Curve.P_256.toECParameterSpec());
final KeyPair keyPair = gen.generateKeyPair();
final JWK jwk = new ECKey.Builder(Curve.P_256, (ECPublicKey) keyPair.getPublic())
.privateKey((ECPrivateKey) keyPair.getPrivate())
.keyUse(KeyUse.ENCRYPTION)
.keyID(UUID.randomUUID().toString())
.algorithm(jweAlgorithm)
.build();
return jwk;
} catch (final NoSuchAlgorithmException | InvalidAlgorithmParameterException e) {
LOG.error("Generating a EC key failed.", e);
}
return null;
}
}
package com.onegini.oidc.encryption;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKMatcher;
import com.nimbusds.jose.jwk.JWKSelector;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyType;
import com.onegini.oidc.model.EncryptionAlgorithms;
import net.minidev.json.JSONObject;
@Service
public class JwkSetProvider {
@Resource
private JweKeyGenerator jweKeyGenerator;
/* For demo purpose, keys are generated each time application starts, but keys should be store in persistence storage */
private final Supplier<JWKSet> jwkSetSupplier = Suppliers.memoize(this::jwksSupplier);
private JWKSet jwksSupplier() {
final List<JWK> jwksList = EncryptionAlgorithms.ENCRYPTION_ALGORITHMS_PRIORITY.stream()
.map(jweAlgorithm -> jweKeyGenerator.generateKey(KeyType.RSA, jweAlgorithm))
.collect(Collectors.toList());
final JWKSet jwkSet = new JWKSet(jwksList);
return jwkSet;
}
private JWKSet getJWKS() {
return jwkSetSupplier.get();
}
public JSONObject getPublicJWKS(final JWEAlgorithm encryptionAlgorithm) {
final JWKSet jwkSet = getJWKS();
final JWKMatcher matcher = new JWKMatcher.Builder().algorithm(encryptionAlgorithm).build();
final List<JWK> matches = new JWKSelector(matcher).select(jwkSet);
if (matches != null && !matches.isEmpty()) {
return new JWKSet(matches).toJSONObject(true);
} else {
final String details = "Not supported JWK was found.";
throw new IllegalArgumentException(details);
}
}
public JWKSet getPrivateJWKS() {
return getJWKS();
}
}
package com.onegini.oidc.model;
import java.util.Arrays;
import java.util.List;
import com.nimbusds.jose.JWEAlgorithm;
public interface EncryptionAlgorithms {
JWEAlgorithm RSA_OAEP_256 = JWEAlgorithm.RSA_OAEP_256;
JWEAlgorithm ECDH_ES = JWEAlgorithm.ECDH_ES;
JWEAlgorithm RSA_OAEP = JWEAlgorithm.RSA_OAEP;
List<JWEAlgorithm> ENCRYPTION_ALGORITHMS_PRIORITY = Arrays.asList(new JWEAlgorithm[]{ RSA_OAEP_256, ECDH_ES, RSA_OAEP });
}
......@@ -5,11 +5,13 @@ public class UserInfo {
private final String id;
private final String name;
private final String idToken;
private final boolean encryptionEnabled;
public UserInfo(final String id, final String name, final String idToken) {
public UserInfo(final String id, final String name, final String idToken, final boolean encryptionEnabled) {
this.id = id;
this.name = name;
this.idToken = idToken;
this.encryptionEnabled = encryptionEnabled;
}
public String getId() {
......@@ -24,4 +26,7 @@ public class UserInfo {
return idToken;
}
public boolean isEncryptionEnabled() {
return encryptionEnabled;
}
}
\ No newline at end of file
......@@ -9,6 +9,8 @@ import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2RestOperations;
......@@ -19,20 +21,26 @@ import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import com.onegini.oidc.model.TokenDetails;
import com.onegini.oidc.model.UserInfo;
import com.nimbusds.jwt.EncryptedJWT;
import com.nimbusds.jwt.JWT;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.JWTParser;
import com.nimbusds.jwt.SignedJWT;
import com.onegini.oidc.model.TokenDetails;
import com.onegini.oidc.model.UserInfo;
public class OpenIdConnectAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final Logger LOG = LoggerFactory.getLogger(OpenIdConnectAuthenticationFilter.class);
@Resource
private OAuth2RestOperations oAuth2RestOperations;
@Resource
private OAuth2ProtectedResourceDetails details;
@Resource
private OpenIdTokenValidatorWrapper openIdTokenValidatorWrapper;
@Resource
private OpenIdTokenDecrypterWrapper openIdTokenDecrypterWrapper;
protected OpenIdConnectAuthenticationFilter(final String defaultFilterProcessesUrl) {
super(defaultFilterProcessesUrl);
......@@ -41,19 +49,24 @@ public class OpenIdConnectAuthenticationFilter extends AbstractAuthenticationPro
@Override
public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) {
// Use ID token inside the Access Token to retrieve user info
final OAuth2AccessToken accessToken = getAccessToken();
try {
// Use ID token inside the Access Token to retrieve user info
final OAuth2AccessToken accessToken = getAccessToken();
final String idToken = accessToken.getAdditionalInformation().get("id_token").toString();
final String idToken = accessToken.getAdditionalInformation().get("id_token").toString();
final JWT jwt = JWTParser.parse(idToken);
final TokenDetails tokenDetails = getTokenDetails(idToken);
final JWTClaimsSet jwtClaimsSet = tokenDetails.getJwtClaimsSet();
final TokenDetails tokenDetails = getTokenDetails(jwt);
final JWTClaimsSet jwtClaimsSet = tokenDetails.getJwtClaimsSet();
final UserInfo principal = createUser(jwtClaimsSet, idToken);
// We do not assign authorities here, but they can be based on claims in the ID token.
final PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(principal, empty(), NO_AUTHORITIES);
token.setDetails(tokenDetails);
return token;
final UserInfo principal = createUser(jwtClaimsSet, jwt);
// We do not assign authorities here, but they can be based on claims in the ID token.
final PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(principal, empty(), NO_AUTHORITIES);
token.setDetails(tokenDetails);
return token;
} catch (final ParseException e) {
throw new BadCredentialsException("Could not obtain user details from token", e);
}
}
private OAuth2AccessToken getAccessToken() {
......@@ -67,25 +80,34 @@ public class OpenIdConnectAuthenticationFilter extends AbstractAuthenticationPro
return accessToken;
}
private TokenDetails getTokenDetails(final String idToken) {
private TokenDetails getTokenDetails(final JWT jwt) {
try {
final JWT jwt = JWTParser.parse(idToken);
openIdTokenValidatorWrapper.validateToken(jwt);
return new TokenDetails(jwt.getJWTClaimsSet());
//If we support only singed JWT or encrypted JWT we can include only adequate part of code
if (jwt instanceof SignedJWT) {
openIdTokenValidatorWrapper.validateToken(jwt);
return new TokenDetails(jwt.getJWTClaimsSet());
} else if (jwt instanceof EncryptedJWT) {
final JWT encryptedJWT = openIdTokenDecrypterWrapper.decryptJWE((EncryptedJWT) jwt);
openIdTokenValidatorWrapper.validateToken(encryptedJWT);
return new TokenDetails(encryptedJWT.getJWTClaimsSet());
} else {
LOG.warn("Plain JWT detected. JWT should be signed.");
return new TokenDetails(jwt.getJWTClaimsSet());
}
} catch (final ParseException e) {
throw new BadCredentialsException("Could not obtain user details from token", e);
}
}
private UserInfo createUser(final JWTClaimsSet jwtClaimsSet, final String idToken) {
private UserInfo createUser(final JWTClaimsSet jwtClaimsSet, final JWT jwt) {
Object name = jwtClaimsSet.getClaim("name");
final boolean encryption = (jwt instanceof EncryptedJWT);
final String idToken = jwt.getParsedString();
if (name == null) {
name = jwtClaimsSet.getSubject();
}
return new UserInfo(jwtClaimsSet.getSubject(), (String) name, idToken);
return new UserInfo(jwtClaimsSet.getSubject(), (String) name, idToken, encryption);
}
}
\ No newline at end of file
package com.onegini.oidc.security;
import java.text.ParseException;
import javax.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.stereotype.Component;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jwt.EncryptedJWT;
import com.nimbusds.jwt.JWT;
import com.nimbusds.jwt.JWTParser;
import com.onegini.oidc.encryption.JweDecrypterService;
/**
* This class is mostly just a wrapper around IDTokenValidator
*/
@Component
public class OpenIdTokenDecrypterWrapper {
private static final Logger LOG = LoggerFactory.getLogger(OpenIdTokenDecrypterWrapper.class);
@Resource
private JweDecrypterService jweDecrypterService;
JWT decryptJWE(final EncryptedJWT encryptedJWT) {
try {
final String idToken = jweDecrypterService.decrypt(encryptedJWT);
return JWTParser.parse(idToken);
} catch (final ParseException | JOSEException e) {
throw new BadCredentialsException("Decrypting JWT went wrong", e);
}
}
}
\ No newline at end of file
......@@ -19,6 +19,8 @@
<dd th:text="${userInfo.id}"></dd>
<dt>Name</dt>
<dd th:text="${userInfo.name}"></dd>
<dt>Encryption enabled</dt>
<dd th:text="${userInfo.encryptionEnabled}"></dd>
<dt>Your id token</dt>
<dd>
<pre th:text="${userInfo.idToken}"></pre>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment