From c40ab0fd597fb718408ff8715bd1efe557e7e5f3 Mon Sep 17 00:00:00 2001 From: Lubos Racansky Date: Thu, 8 Aug 2024 09:00:26 +0200 Subject: [PATCH] Fix #532: OIDC: Implement activation using OAuth 2.0, openid scope --- pom.xml | 8 + .../rest/api/model/entity/ActivationType.java | 8 + powerauth-restful-security-spring/pom.xml | 15 +- ...AuthApplicationConfigurationException.java | 65 +++++++ .../api/spring/service/ActivationService.java | 79 +++++++- .../oidc/ClientAuthenticationMethod.java | 37 ++++ .../spring/service/oidc/IdTokenValidator.java | 82 +++++++++ ...OidcActivationConfigurationProperties.java | 44 +++++ .../service/oidc/OidcActivationContext.java | 39 ++++ .../oidc/OidcApplicationConfiguration.java | 62 +++++++ .../OidcApplicationConfigurationService.java | 108 +++++++++++ .../service/oidc/OidcConfigurationQuery.java | 33 ++++ .../api/spring/service/oidc/OidcHandler.java | 173 ++++++++++++++++++ .../spring/service/oidc/OidcTokenClient.java | 98 ++++++++++ .../api/spring/service/oidc/TokenRequest.java | 38 ++++ .../spring/service/oidc/TokenResponse.java | 43 +++++ .../service/oidc/IdTokenValidatorTest.java | 107 +++++++++++ ...dcApplicationConfigurationServiceTest.java | 154 ++++++++++++++++ 18 files changed, 1189 insertions(+), 4 deletions(-) create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/exception/PowerAuthApplicationConfigurationException.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/ClientAuthenticationMethod.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidator.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationConfigurationProperties.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationContext.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfiguration.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationService.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcConfigurationQuery.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcHandler.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcTokenClient.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenRequest.java create mode 100644 powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenResponse.java create mode 100644 powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidatorTest.java create mode 100644 powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationServiceTest.java diff --git a/pom.xml b/pom.xml index e6a92cc0..747e481d 100644 --- a/pom.xml +++ b/pom.xml @@ -131,6 +131,14 @@ lombok provided + + + + io.netty + netty-resolver-dns-native-macos + runtime + osx-aarch_64 + diff --git a/powerauth-restful-model/src/main/java/io/getlime/security/powerauth/rest/api/model/entity/ActivationType.java b/powerauth-restful-model/src/main/java/io/getlime/security/powerauth/rest/api/model/entity/ActivationType.java index 395b16de..2f401d69 100644 --- a/powerauth-restful-model/src/main/java/io/getlime/security/powerauth/rest/api/model/entity/ActivationType.java +++ b/powerauth-restful-model/src/main/java/io/getlime/security/powerauth/rest/api/model/entity/ActivationType.java @@ -33,9 +33,17 @@ public enum ActivationType { /** * Activation via custom credentials. + * @deprecated Use {@link #DIRECT} instead. */ + @Deprecated CUSTOM, + /** + * Direct activation, alias for {@link #CUSTOM}. + * The method could be specified, for example {@code OIDC}. + */ + DIRECT, + /** * Activation via recovery code. */ diff --git a/powerauth-restful-security-spring/pom.xml b/powerauth-restful-security-spring/pom.xml index a97691aa..016b4a74 100644 --- a/powerauth-restful-security-spring/pom.xml +++ b/powerauth-restful-security-spring/pom.xml @@ -54,10 +54,21 @@ + + + org.springframework.security + spring-security-oauth2-client + + + + org.springframework.security + spring-security-oauth2-jose + + - org.junit.jupiter - junit-jupiter-engine + org.springframework.boot + spring-boot-starter-test test diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/exception/PowerAuthApplicationConfigurationException.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/exception/PowerAuthApplicationConfigurationException.java new file mode 100644 index 00000000..031286d1 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/exception/PowerAuthApplicationConfigurationException.java @@ -0,0 +1,65 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.exception; + +import java.io.Serial; + +/** + * Exception related to application configuration. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +public class PowerAuthApplicationConfigurationException extends Exception { + + @Serial + private static final long serialVersionUID = 8677977961740746599L; + + /** + * No-arg constructor. + */ + public PowerAuthApplicationConfigurationException() { + super(); + } + + /** + * Constructor with a custom error message. + * @param message Error message. + */ + public PowerAuthApplicationConfigurationException(String message) { + super(message); + } + + /** + * Constructor with a cause. + * @param cause Error cause. + */ + public PowerAuthApplicationConfigurationException(Throwable cause) { + super(cause); + } + + /** + * Constructor with a message and cause. + * @param message Error message. + * @param cause Error cause. + */ + public PowerAuthApplicationConfigurationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/ActivationService.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/ActivationService.java index e20748ba..44791cef 100644 --- a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/ActivationService.java +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/ActivationService.java @@ -42,6 +42,8 @@ import io.getlime.security.powerauth.rest.api.spring.model.UserInfoContext; import io.getlime.security.powerauth.rest.api.spring.provider.CustomActivationProvider; import io.getlime.security.powerauth.rest.api.spring.provider.UserInfoProvider; +import io.getlime.security.powerauth.rest.api.spring.service.oidc.OidcActivationContext; +import io.getlime.security.powerauth.rest.api.spring.service.oidc.OidcHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -65,9 +67,12 @@ @Slf4j public class ActivationService { + private static final String METHOD_OIDC = "oidc"; + private final PowerAuthClient powerAuthClient; private final HttpCustomizationService httpCustomizationService; private final ActivationContextConverter activationContextConverter; + private final OidcHandler oidcHandler; private PowerAuthApplicationConfiguration applicationConfiguration; private CustomActivationProvider activationProvider; @@ -81,10 +86,16 @@ public class ActivationService { * @param activationContextConverter Activation context converter. */ @Autowired - public ActivationService(PowerAuthClient powerAuthClient, HttpCustomizationService httpCustomizationService, ActivationContextConverter activationContextConverter) { + public ActivationService( + PowerAuthClient powerAuthClient, + HttpCustomizationService httpCustomizationService, + ActivationContextConverter activationContextConverter, + OidcHandler oidcHandler) { + this.powerAuthClient = powerAuthClient; this.httpCustomizationService = httpCustomizationService; this.activationContextConverter = activationContextConverter; + this.oidcHandler = oidcHandler; } /** @@ -134,7 +145,8 @@ public ActivationLayer1Response createActivation(ActivationLayer1Request request return switch (type) { // Regular activation which uses "code" identity attribute case CODE -> processCodeActivation(eciesContext, request); - case CUSTOM -> processCustomActivation(eciesContext, request); + // Direct activation for known specific methods, otherwise fallback to custom activation + case CUSTOM, DIRECT -> processDirectOrCustomActivation(eciesContext, request, type, identity); case RECOVERY -> processRecoveryCodeActivation(eciesContext, request); }; } catch (PowerAuthClientException ex) { @@ -430,6 +442,69 @@ private ActivationLayer1Response processCustomActivation(final EncryptionContext response.getNonce(), response.getTimestamp(), processedCustomAttributes, userInfo); } + private ActivationLayer1Response processDirectOrCustomActivation(final EncryptionContext eciesContext, final ActivationLayer1Request request, final ActivationType type, final Map identity) throws PowerAuthActivationException, PowerAuthClientException { + if (type == ActivationType.DIRECT) { + final String method = identity.get("method"); + if (METHOD_OIDC.equals(method)) { + return processOidcActivation(eciesContext, request); + } else { + logger.info("Unknown method: {} of direct activation, fallback to custom activation", method); + } + } + + return processCustomActivation(eciesContext, request); + } + + private ActivationLayer1Response processOidcActivation(final EncryptionContext eciesContext, final ActivationLayer1Request request) throws PowerAuthClientException, PowerAuthActivationException { + logger.debug("Processing direct OIDC activation."); + + final Map identity = request.getIdentityAttributes(); + final OidcActivationContext oAuthActivationContext = OidcActivationContext.builder() + .providerId(identity.get("providerId")) + .code(identity.get("code")) + .nonce(identity.get("nonce")) + .applicationKey(eciesContext.getApplicationKey()) + .build(); + + final String userId = oidcHandler.retrieveUserId(oAuthActivationContext); + + final EciesEncryptedRequest activationData = request.getActivationData(); + final Map customAttributes = Objects.requireNonNullElse(request.getCustomAttributes(), new HashMap<>()); + + final CreateActivationRequest createRequest = new CreateActivationRequest(); + createRequest.setUserId(userId); + createRequest.setGenerateRecoveryCodes(false); + createRequest.setApplicationKey(eciesContext.getApplicationKey()); + createRequest.setEphemeralPublicKey(activationData.getEphemeralPublicKey()); + createRequest.setEncryptedData(activationData.getEncryptedData()); + createRequest.setMac(activationData.getMac()); + createRequest.setNonce(activationData.getNonce()); + createRequest.setProtocolVersion(eciesContext.getVersion()); + createRequest.setTimestamp(activationData.getTimestamp()); + + final CreateActivationResponse response = powerAuthClient.createActivation( + createRequest, + httpCustomizationService.getQueryParams(), + httpCustomizationService.getHttpHeaders() + ); + + final String activationId = response.getActivationId(); + final String applicationId = response.getApplicationId(); + + commitActivation(activationId); + + final UserInfoContext userInfoContext = UserInfoContext.builder() + .stage(UserInfoStage.ACTIVATION_PROCESS_CUSTOM) + .userId(userId) + .activationId(activationId) + .applicationId(applicationId) + .build(); + final Map userInfo = processUserInfo(userInfoContext); + + return prepareEncryptedResponse(response.getEncryptedData(), response.getMac(), + response.getNonce(), response.getTimestamp(), customAttributes, userInfo); + } + private static void checkIdentityAttributesPresent(final Map identity) throws PowerAuthActivationException { if (CollectionUtils.isEmpty(identity)) { throw new PowerAuthActivationException("Identity attributes are missing for activation."); diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/ClientAuthenticationMethod.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/ClientAuthenticationMethod.java new file mode 100644 index 00000000..cd03a641 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/ClientAuthenticationMethod.java @@ -0,0 +1,37 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * OIDC client authentication methods. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +enum ClientAuthenticationMethod { + + @JsonProperty("client_secret_basic") + CLIENT_SECRET_BASIC, + + @JsonProperty("client_secret_post") + CLIENT_SECRET_POST + +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidator.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidator.java new file mode 100644 index 00000000..90c72fa6 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidator.java @@ -0,0 +1,82 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.jose.jws.JwsAlgorithms; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * Additional ID token validations. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Slf4j +final class IdTokenValidator { + + private IdTokenValidator() { + throw new IllegalStateException("Should not be instantiated"); + } + + static boolean isAtHashValid(final Jwt idToken, final String accessToken) { + final String atHash = idToken.getClaimAsString("at_hash"); + return atHash == null || isAtHashValid(accessToken, atHash, idToken.getHeaders().get("alg").toString()); + } + + static boolean isNonceValid(final Jwt idToken, final String nonce) { + return nonce.equals(idToken.getClaimAsString("nonce")); + } + + /** + *
    + *
  1. Hash the octets of the ASCII representation of the access_token with the hash algorithm for the alg Header Parameter of the ID Token's JOSE Header. For instance, if the alg is RS256, the hash algorithm used is SHA-256.
  2. + *
  3. Take the left-most half of the hash and base64url-encode it.
  4. + *
  5. The value of at_hash in the ID Token MUST match the value produced in the previous step.
  6. + *
+ * + * @see 3.2.2.9. Access Token Validation + */ + private static boolean isAtHashValid(final String accessToken, final String atHash, final String signatureAlgorithm) { + try { + final MessageDigest digest = MessageDigest.getInstance(mapHashAlgorithm(signatureAlgorithm)); + final byte[] hash = digest.digest(accessToken.getBytes()); + final byte[] leftHalf = new byte[hash.length / 2]; + System.arraycopy(hash, 0, leftHalf, 0, leftHalf.length); + final String computedAtHash = Base64.getUrlEncoder().withoutPadding().encodeToString(leftHalf); + return atHash.equals(computedAtHash); + } catch (NoSuchAlgorithmException e) { + logger.error("Unable to validate at_hash", e); + return false; + } + } + + private static String mapHashAlgorithm(final String signatureAlgorithm) throws NoSuchAlgorithmException { + return switch (signatureAlgorithm) { + case JwsAlgorithms.RS256, JwsAlgorithms.ES256 -> "SHA-256"; + case JwsAlgorithms.RS384, JwsAlgorithms.ES384 -> "SHA-384"; + case JwsAlgorithms.RS512, JwsAlgorithms.ES512 -> "SHA-512"; + default -> throw new NoSuchAlgorithmException("Unsupported signature algorithm: " + signatureAlgorithm); + }; + } +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationConfigurationProperties.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationConfigurationProperties.java new file mode 100644 index 00000000..ea1c4e79 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationConfigurationProperties.java @@ -0,0 +1,44 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.wultra.core.rest.client.base.RestClientConfiguration; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * OIDC (OpenID Connect) token endpoint request. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Component +@ConfigurationProperties(prefix = "activation.oidc") +@Getter +@Setter +class OidcActivationConfigurationProperties { + + /** + * REST client configuration + */ + private RestClientConfiguration restClientConfig = new RestClientConfiguration(); + +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationContext.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationContext.java new file mode 100644 index 00000000..c4e10a64 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcActivationContext.java @@ -0,0 +1,39 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import lombok.Builder; +import lombok.Getter; + +/** + * OIDC (OpenID Connect) activation context. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Builder +@Getter +public class OidcActivationContext { + + private String code; + private String nonce; + private String applicationKey; + private String providerId; + +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfiguration.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfiguration.java new file mode 100644 index 00000000..c78b20c1 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfiguration.java @@ -0,0 +1,62 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import lombok.Getter; +import lombok.Setter; + +/** + * OIDC activation configuration. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Getter +@Setter +public class OidcApplicationConfiguration { + + private String providerId; + + private String clientId; + + private String clientSecret; + + /** + * Optional. If emtpy, {@code client_secret_basic} is used. + */ + private ClientAuthenticationMethod clientAuthenticationMethod; + + private String issuerUri; + + private String tokenUri; + + private String jwkSetUri; + + private String redirectUri; + + private String scopes; + + private String authorizeUri; + + /** + * Optional. If empty, {code RS256} is used. + */ + private String signatureAlgorithm; + +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationService.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationService.java new file mode 100644 index 00000000..1c79cfe1 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationService.java @@ -0,0 +1,108 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wultra.security.powerauth.client.PowerAuthClient; +import com.wultra.security.powerauth.client.model.entity.ApplicationConfigurationItem; +import com.wultra.security.powerauth.client.model.error.PowerAuthClientException; +import com.wultra.security.powerauth.client.model.request.GetApplicationConfigRequest; +import com.wultra.security.powerauth.client.model.request.LookupApplicationByAppKeyRequest; +import com.wultra.security.powerauth.client.model.response.GetApplicationConfigResponse; +import com.wultra.security.powerauth.client.model.response.LookupApplicationByAppKeyResponse; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthApplicationConfigurationException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Objects; + +/** + * Application configuration service for OIDC. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Component +@AllArgsConstructor +@Slf4j +public class OidcApplicationConfigurationService { + + private static final String OAUTH2_PROVIDERS = "oauth2_providers"; + + private final PowerAuthClient powerAuthClient; + + private final ObjectMapper objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + /** + * Provide OIDC application configuration. + * + * @param request Query object. + * @return OIDC application configuration + * @throws PowerAuthApplicationConfigurationException in case of error. + */ + public OidcApplicationConfiguration fetchOidcApplicationConfiguration(final OidcConfigurationQuery request) throws PowerAuthApplicationConfigurationException { + try { + final String applicationId = fetchApplicationIdByApplicationKey(request.applicationKey()); + + final GetApplicationConfigRequest configRequest = new GetApplicationConfigRequest(); + configRequest.setApplicationId(applicationId); + + final GetApplicationConfigResponse applicationConfig = powerAuthClient.getApplicationConfig(configRequest); + return applicationConfig.getApplicationConfigs().stream() + .filter(it -> it.getKey().equals(OAUTH2_PROVIDERS)) + .findFirst() + .map(ApplicationConfigurationItem::getValues) + .map(it -> convert(it, request.providerId())) + .orElseThrow(() -> + new PowerAuthApplicationConfigurationException("Fetching application configuration failed, application ID: %s, provider ID: %s".formatted(applicationId, request.providerId()))); + } catch (PowerAuthClientException e) { + throw new PowerAuthApplicationConfigurationException("Fetching application configuration failed.", e); + } + } + + private OidcApplicationConfiguration convert(List values, String providerId) { + return values.stream() + .map(this::convert) + .filter(Objects::nonNull) + .filter(it -> it.getProviderId().equals(providerId)) + .findFirst() + .orElse(null); + } + + private OidcApplicationConfiguration convert(Object value) { + try { + return objectMapper.convertValue(value, OidcApplicationConfiguration.class); + } catch (IllegalArgumentException e) { + logger.warn("Unable to convert {}", value, e); + return null; + } + } + + private String fetchApplicationIdByApplicationKey(final String applicationKey) throws PowerAuthClientException { + final LookupApplicationByAppKeyRequest request = new LookupApplicationByAppKeyRequest(); + request.setApplicationKey(applicationKey); + + final LookupApplicationByAppKeyResponse applicationResponse = powerAuthClient.lookupApplicationByAppKey(request); + return applicationResponse.getApplicationId(); + } + +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcConfigurationQuery.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcConfigurationQuery.java new file mode 100644 index 00000000..4017b843 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcConfigurationQuery.java @@ -0,0 +1,33 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import lombok.Builder; + +/** + * Query for {@link OidcApplicationConfigurationService}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + * @param providerId + * @param applicationKey + */ +@Builder +public record OidcConfigurationQuery(String providerId, String applicationKey) { +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcHandler.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcHandler.java new file mode 100644 index 00000000..399590d7 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcHandler.java @@ -0,0 +1,173 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.wultra.core.rest.client.base.RestClientException; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthActivationException; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthApplicationConfigurationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Wrap OIDC (OpenID Connect) client calls, add other logic such as validation. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Component +@Slf4j +public class OidcHandler { + + private final Map signatureAlgorithms = new ConcurrentHashMap<>(); + + private final OidcIdTokenDecoderFactory oidcIdTokenDecoderFactory; + + private final OidcTokenClient tokenClient; + + private final OidcApplicationConfigurationService applicationConfigurationService; + + @Autowired + OidcHandler(final OidcTokenClient tokenClient, final OidcApplicationConfigurationService applicationConfigurationService) { + this.tokenClient = tokenClient; + this.applicationConfigurationService = applicationConfigurationService; + oidcIdTokenDecoderFactory = new OidcIdTokenDecoderFactory(); + oidcIdTokenDecoderFactory.setJwsAlgorithmResolver(clientRegistration -> signatureAlgorithms.get(clientRegistration.getRegistrationId())); + } + + /** + * Retrieve user ID from a token, using {@code authorization_code} flow. The token is verified first. + * + * @param request Parameter object. + * @return User ID. + * @throws PowerAuthActivationException in case of error. + */ + public String retrieveUserId(final OidcActivationContext request) throws PowerAuthActivationException { + final OidcApplicationConfiguration oidcApplicationConfiguration = fetchOidcApplicationConfiguration(request); + + final ClientRegistration clientRegistration = createClientRegistration(request.getProviderId(), oidcApplicationConfiguration); + + signatureAlgorithms.putIfAbsent(clientRegistration.getRegistrationId(), mapSignatureAlgorithmFromConfiguration(oidcApplicationConfiguration)); + + final TokenRequest tokenRequest = TokenRequest.builder() + .code(request.getCode()) + .clientRegistration(clientRegistration) + .build(); + + final TokenResponse tokenResponse = fetchToken(tokenRequest); + final Jwt idToken = verifyAndDecode(tokenResponse, clientRegistration, request.getNonce()); + + return idToken.getSubject(); + } + + private static ClientRegistration createClientRegistration(final String providerId, final OidcApplicationConfiguration oidcApplicationConfiguration) { + logger.debug("Trying to configure via {}/.well-known/openid-configuration", oidcApplicationConfiguration.getIssuerUri()); + try { + return ClientRegistrations.fromOidcIssuerLocation(oidcApplicationConfiguration.getIssuerUri()) + .clientId(oidcApplicationConfiguration.getClientId()) + .clientSecret(oidcApplicationConfiguration.getClientSecret()) + .redirectUri(oidcApplicationConfiguration.getRedirectUri()) + .build(); + } catch (Exception e) { + logger.info("Unable to reach {}/.well-known/openid-configuration, fallback to manual config; {}", oidcApplicationConfiguration.getIssuerUri(), e.getMessage()); + logger.debug("Unable to reach {}/.well-known/openid-configuration", oidcApplicationConfiguration.getIssuerUri(), e); + return ClientRegistration.withRegistrationId(providerId) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientId(oidcApplicationConfiguration.getClientId()) + .clientSecret(oidcApplicationConfiguration.getClientSecret()) + .clientAuthenticationMethod(convert(oidcApplicationConfiguration.getClientAuthenticationMethod())) + .tokenUri(oidcApplicationConfiguration.getTokenUri()) + .jwkSetUri(oidcApplicationConfiguration.getJwkSetUri()) + .authorizationUri(oidcApplicationConfiguration.getAuthorizeUri()) + .redirectUri(oidcApplicationConfiguration.getRedirectUri()) + .build(); + } + } + + private static ClientAuthenticationMethod convert(final io.getlime.security.powerauth.rest.api.spring.service.oidc.ClientAuthenticationMethod source) { + return switch(source) { + case CLIENT_SECRET_POST -> ClientAuthenticationMethod.CLIENT_SECRET_POST; + case CLIENT_SECRET_BASIC -> ClientAuthenticationMethod.CLIENT_SECRET_BASIC; + }; + } + + private OidcApplicationConfiguration fetchOidcApplicationConfiguration(final OidcActivationContext request) throws PowerAuthActivationException { + try { + return applicationConfigurationService.fetchOidcApplicationConfiguration(OidcConfigurationQuery.builder() + .applicationKey(request.getApplicationKey()) + .providerId(request.getProviderId()) + .build()); + } catch (PowerAuthApplicationConfigurationException e) { + throw new PowerAuthActivationException(e); + } + } + + private TokenResponse fetchToken(final TokenRequest tokenRequest) throws PowerAuthActivationException { + final String clientId = tokenRequest.getClientRegistration().getClientId(); + logger.debug("Fetching token, clientId: {}", clientId); + try { + final TokenResponse response = tokenClient.fetchTokenResponse(tokenRequest); + logger.debug("Token fetched, verifying, clientId: {}", clientId); + return response; + } catch (RestClientException e) { + throw new PowerAuthActivationException("Unable to get token response", e); + } + } + + private Jwt verifyAndDecode(final TokenResponse tokenResponse, final ClientRegistration clientRegistration, final String nonce) throws PowerAuthActivationException { + final JwtDecoder jwtDecoder = oidcIdTokenDecoderFactory.createDecoder(clientRegistration); + + try { + final Jwt idTokenJwt = jwtDecoder.decode(tokenResponse.getIdToken()); + validate(idTokenJwt, nonce, tokenResponse); + return idTokenJwt; + } catch (JwtException e) { + throw new PowerAuthActivationException("Decoding JWT failed", e); + } + } + + private static void validate(final Jwt idTokenJwt, final String nonce, final TokenResponse tokenResponse) throws PowerAuthActivationException { + if (!IdTokenValidator.isNonceValid(idTokenJwt, nonce)) { + throw new PowerAuthActivationException("The nonce does not match"); + } + if (!IdTokenValidator.isAtHashValid(idTokenJwt, tokenResponse.getAccessToken())) { + throw new PowerAuthActivationException("The at_hash does not match"); + } + } + + private static SignatureAlgorithm mapSignatureAlgorithmFromConfiguration(final OidcApplicationConfiguration oidcApplicationConfiguration) { + final String signatureAlgorithmString = oidcApplicationConfiguration.getSignatureAlgorithm(); + final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.from(signatureAlgorithmString); + return Objects.requireNonNullElse(signatureAlgorithm, SignatureAlgorithm.RS256); + } + +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcTokenClient.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcTokenClient.java new file mode 100644 index 00000000..9a7fdb61 --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcTokenClient.java @@ -0,0 +1,98 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.wultra.core.rest.client.base.DefaultRestClient; +import com.wultra.core.rest.client.base.RestClient; +import com.wultra.core.rest.client.base.RestClientConfiguration; +import com.wultra.core.rest.client.base.RestClientException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * OIDC (OpenID Connect) client. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Component +@AllArgsConstructor +@Slf4j +class OidcTokenClient { + + private OidcActivationConfigurationProperties configurationProperties; + + /** + * Call token endpoint using {@code authorization_code} flow. Mind that the token is not verified yet. + * + * @param tokenRequest Token request. + * @return Token response. + * @throws RestClientException in case of error. + */ + TokenResponse fetchTokenResponse(final TokenRequest tokenRequest) throws RestClientException { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final org.springframework.security.oauth2.core.ClientAuthenticationMethod clientAuthenticationMethod = tokenRequest.getClientRegistration().getClientAuthenticationMethod(); + logger.debug("Using ClientAuthenticationMethod: {}", clientAuthenticationMethod); + + final ClientRegistration clientRegistration = tokenRequest.getClientRegistration(); + + final MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("grant_type", "authorization_code"); + map.add("client_id", clientRegistration.getClientId()); + map.add("code", tokenRequest.getCode()); + map.add("redirect_uri", clientRegistration.getRedirectUri()); + + if (clientAuthenticationMethod == org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_POST) { + map.add("client_secret", clientRegistration.getClientSecret()); + } + + final RestClient restClient = createRestClient(tokenRequest); + + final String tokenUrl = clientRegistration.getProviderDetails().getTokenUri(); + logger.debug("Calling token endpoint: {}", tokenUrl); + final ResponseEntity response = restClient.post(tokenUrl, map, null, headers, new ParameterizedTypeReference<>(){}); + logger.debug("Token endpoint call finished: {}", tokenUrl); + + if (response == null) { + throw new RestClientException("Response is null"); + } + + return response.getBody(); + } + + private RestClient createRestClient(final TokenRequest tokenRequest) throws RestClientException { + final RestClientConfiguration restClientConfiguration = configurationProperties.getRestClientConfig(); + final org.springframework.security.oauth2.core.ClientAuthenticationMethod clientAuthenticationMethod = tokenRequest.getClientRegistration().getClientAuthenticationMethod(); + restClientConfiguration.setHttpBasicAuthEnabled(clientAuthenticationMethod == null || clientAuthenticationMethod == org.springframework.security.oauth2.core.ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + restClientConfiguration.setHttpBasicAuthUsername(tokenRequest.getClientRegistration().getClientId()); + restClientConfiguration.setHttpBasicAuthPassword(tokenRequest.getClientRegistration().getClientSecret()); + + return new DefaultRestClient(restClientConfiguration); + } +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenRequest.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenRequest.java new file mode 100644 index 00000000..b5f6372c --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenRequest.java @@ -0,0 +1,38 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.oauth2.client.registration.ClientRegistration; + +/** + * OIDC token endpoint request. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Builder +@Getter +class TokenRequest { + + private String code; + private ClientRegistration clientRegistration; + +} diff --git a/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenResponse.java b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenResponse.java new file mode 100644 index 00000000..b21774ee --- /dev/null +++ b/powerauth-restful-security-spring/src/main/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/TokenResponse.java @@ -0,0 +1,43 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +/** + * OIDC token endpoint response. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@Getter +@Setter +public class TokenResponse { + + @JsonProperty("id_token") + private String idToken; + + @JsonProperty("access_token") + private String accessToken; + +} diff --git a/powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidatorTest.java b/powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidatorTest.java new file mode 100644 index 00000000..3aa8b5d7 --- /dev/null +++ b/powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/IdTokenValidatorTest.java @@ -0,0 +1,107 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +import java.text.ParseException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test for {@link IdTokenValidator}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +class IdTokenValidatorTest { + + // test vector from https://stackoverflow.com/a/36708354/204950 + private static final String ACCESS_TOKEN = "ya29.eQGmYe6H3fP_d65AY0pOMCFikA0f4hzVZGmTPPyv7k_l6HzlEIpFXnXGZjcMhkyyuqSMtN_RTGJ-xg"; + + /* + jwt.io + { + "sub": "1234567890", + "name": "John Doe", + "aud": "pas", + "nonce": "a184d4a4sd7asd74a8sda", + "at_hash": "lOtI0BRou0Z4LPtQuE8cCw" + } + */ + private static final String ID_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYXVkIjoicGFzIiwibm9uY2UiOiJhMTg0ZDRhNHNkN2FzZDc0YThzZGEiLCJhdF9oYXNoIjoibE90STBCUm91MFo0TFB0UXVFOGNDdyJ9.KZqKWSu4fD8s95E5l4Z8qqHLo5iOeu4Ks4NPMRHhhdDqszXREDrRF9nTVOiJMrYVeYnI7dPtixtL9JPyODyYAQ070Qa0bkvJ2-OTSlESgVuO62QgRXP8Ba_uN_UT_xLRKoSbgPstuv5tjHT34iugYy48Meheraoj5v-QDo8glltiWR8Bo_WOz4SrtHezD4DqKRsnE2DlTYkVqmqK8s-wgik67JhFygupSBLsmMi1zRWjThjFibWRR31kFDc1jRuUWl1RidYPHMIZkUvMT3GQWL0B45ET1-fhrpg_GQZtlLadADb24QtY06X2peyFZ3JrvYIsxf4F2R1F6UUDzDcN0g"; + + private final JwtDecoder jwtDecoder = token -> { + try { + final SignedJWT signedJWT = SignedJWT.parse(token); + final JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); + final JWSHeader header = signedJWT.getHeader(); + + return Jwt.withTokenValue(token) + .headers(headers -> headers.putAll(header.toJSONObject())) + .claims(claims -> claims.putAll(claimsSet.getClaims())) + .build(); + } catch (ParseException e) { + throw new RuntimeException("Invalid token", e); + } + }; + + @Test + void testNonceValidator_success() { + final Jwt jwt = jwtDecoder.decode(ID_TOKEN); + + final boolean result = IdTokenValidator.isNonceValid(jwt, "a184d4a4sd7asd74a8sda"); + + assertTrue(result); + } + + @Test + void testNonceValidator_invalid() { + final Jwt jwt = jwtDecoder.decode(ID_TOKEN); + + final boolean result = IdTokenValidator.isNonceValid(jwt, "invalid"); + + assertFalse(result); + } + + @Test + void testAtHash_success() { + final Jwt jwt = jwtDecoder.decode(ID_TOKEN); + + final boolean result = IdTokenValidator.isAtHashValid(jwt, ACCESS_TOKEN); + + assertTrue(result); + } + + @Test + void testAtHashValidator_invalid() { + final Jwt jwt = jwtDecoder.decode(ID_TOKEN); + + final boolean result = IdTokenValidator.isAtHashValid(jwt, "invalid access token"); + + assertFalse(result); + } + +} \ No newline at end of file diff --git a/powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationServiceTest.java b/powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationServiceTest.java new file mode 100644 index 00000000..9b36cd50 --- /dev/null +++ b/powerauth-restful-security-spring/src/test/java/io/getlime/security/powerauth/rest/api/spring/service/oidc/OidcApplicationConfigurationServiceTest.java @@ -0,0 +1,154 @@ +/* + * PowerAuth integration libraries for RESTful API applications, examples and + * related software components + * + * Copyright (C) 2024 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.getlime.security.powerauth.rest.api.spring.service.oidc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wultra.security.powerauth.client.PowerAuthClient; +import com.wultra.security.powerauth.client.model.request.GetApplicationConfigRequest; +import com.wultra.security.powerauth.client.model.request.LookupApplicationByAppKeyRequest; +import com.wultra.security.powerauth.client.model.response.GetApplicationConfigResponse; +import com.wultra.security.powerauth.client.model.response.LookupApplicationByAppKeyResponse; +import io.getlime.security.powerauth.rest.api.spring.exception.PowerAuthApplicationConfigurationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +/** + * Test for {@link OidcApplicationConfigurationService}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@ExtendWith(MockitoExtension.class) +class OidcApplicationConfigurationServiceTest { + + @Mock + private PowerAuthClient powerAuthClient; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks + private OidcApplicationConfigurationService tested; + + @Test + void testFetchOidcApplicationConfiguration() throws Exception { + final LookupApplicationByAppKeyRequest lookupRequest = new LookupApplicationByAppKeyRequest(); + lookupRequest.setApplicationKey("AIsOlIghnLztV2np3SANnQ=="); + + final LookupApplicationByAppKeyResponse lookupResponse = new LookupApplicationByAppKeyResponse(); + lookupResponse.setApplicationId("application-1"); + + when(powerAuthClient.lookupApplicationByAppKey(lookupRequest)) + .thenReturn(lookupResponse); + + final GetApplicationConfigRequest configRequest = new GetApplicationConfigRequest(); + configRequest.setApplicationId("application-1"); + + final GetApplicationConfigResponse configResponse = createResponse(); + when(powerAuthClient.getApplicationConfig(configRequest)) + .thenReturn(configResponse); + + final OidcApplicationConfiguration result = tested.fetchOidcApplicationConfiguration(OidcConfigurationQuery.builder() + .applicationKey("AIsOlIghnLztV2np3SANnQ==") + .providerId("xyz999") + .build()); + + assertEquals("xyz999", result.getProviderId()); + assertEquals("jabberwocky", result.getClientId()); + assertEquals("https://redirect.example.com", result.getRedirectUri()); + assertEquals("https://issuer.example.com", result.getIssuerUri()); + assertEquals("openid", result.getScopes()); + assertEquals("https://token.example.com", result.getTokenUri()); + assertEquals("https://authorize.example.com", result.getAuthorizeUri()); + assertEquals("ES256", result.getSignatureAlgorithm()); + } + + @Test + void testFetchOidcApplicationConfiguration_invalidProviderId() throws Exception { + final LookupApplicationByAppKeyRequest lookupRequest = new LookupApplicationByAppKeyRequest(); + lookupRequest.setApplicationKey("AIsOlIghnLztV2np3SANnQ=="); + + final LookupApplicationByAppKeyResponse lookupResponse = new LookupApplicationByAppKeyResponse(); + lookupResponse.setApplicationId("application-1"); + + when(powerAuthClient.lookupApplicationByAppKey(lookupRequest)) + .thenReturn(lookupResponse); + + final GetApplicationConfigRequest configRequest = new GetApplicationConfigRequest(); + configRequest.setApplicationId("application-1"); + + final GetApplicationConfigResponse configResponse = createResponse(); + when(powerAuthClient.getApplicationConfig(configRequest)) + .thenReturn(configResponse); + + final Exception e = assertThrows(PowerAuthApplicationConfigurationException.class, () -> tested.fetchOidcApplicationConfiguration(OidcConfigurationQuery.builder() + .applicationKey("AIsOlIghnLztV2np3SANnQ==") + .providerId("non-existing") + .build())); + + assertEquals("Fetching application configuration failed, application ID: application-1, provider ID: non-existing", e.getMessage()); + } + + private GetApplicationConfigResponse createResponse() throws JsonProcessingException { + final String json = """ + { + "applicationId": "application-1", + "applicationConfigs": [ + { + "key": "oauth2_providers", + "values": [ + { + "providerId": "abc123", + "clientId": "1234567890abcdef", + "clientSecret": "top secret", + "scopes": "openid", + "authorizeUri": "https://...", + "redirectUri": "https://...", + "tokenUri": "https://...", + "issuerUri": "https://...", + "userInfoUri": "https://..." + }, + { + "providerId": "xyz999", + "clientId": "jabberwocky", + "clientSecret": "top secret", + "scopes": "openid", + "authorizeUri": "https://authorize.example.com", + "redirectUri": "https://redirect.example.com", + "issuerUri": "https://issuer.example.com", + "tokenUri": "https://token.example.com", + "userInfoUri": "https://...", + "signatureAlgorithm": "ES256" + } + ] + } + ] + } + """; + + return objectMapper.readValue(json, GetApplicationConfigResponse.class); + } +} \ No newline at end of file