From 2b32fe73ce49665ebafc0d94385f43d6016086ba Mon Sep 17 00:00:00 2001 From: Mauro Mugnaini Date: Fri, 28 May 2021 18:29:06 +0200 Subject: [PATCH] First share with auto-discovery of the endpoint in scope with ic-client and gxREST use --- .gitignore | 74 ++++ CHANGELOG.md | 6 + FUNDING.md | 26 ++ LICENSE.md | 311 ++++++++++++++++ README.md | 44 +++ pom.xml | 110 ++++++ .../gcube/common/keycloak/AbstractPlugin.java | 25 ++ .../keycloak/DefaultKeycloakClient.java | 161 +++++++++ .../gcube/common/keycloak/KeycloakClient.java | 82 +++++ .../keycloak/KeycloakClientException.java | 70 ++++ .../keycloak/KeycloakClientFactory.java | 15 + .../common/keycloak/model/AccessToken.java | 154 ++++++++ .../keycloak/model/AddressClaimSet.java | 80 +++++ .../gcube/common/keycloak/model/IDToken.java | 337 ++++++++++++++++++ .../common/keycloak/model/JsonWebToken.java | 211 +++++++++++ .../common/keycloak/model/ModelUtils.java | 97 +++++ .../common/keycloak/model/RefreshToken.java | 21 ++ .../common/keycloak/model/TokenResponse.java | 133 +++++++ .../gcube/common/keycloak/model/UserInfo.java | 309 ++++++++++++++++ .../model/util/StringListMapDeserializer.java | 41 +++ .../model/util/StringOrArrayDeserializer.java | 29 ++ .../model/util/StringOrArraySerializer.java | 25 ++ .../common/keycloak/model/util/Time.java | 67 ++++ .../common/keycloak/TestKeycloakClient.java | 69 ++++ .../org/gcube/common/keycloak/TestModels.java | 89 +++++ src/test/resources/log4j.xml | 30 ++ src/test/resources/oidc-token-response.json | 10 + src/test/resources/uma-access-token.json | 63 ++++ src/test/resources/uma-refresh-token.json | 36 ++ src/test/resources/uma-token-response.json | 9 + 30 files changed, 2734 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 FUNDING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/org/gcube/common/keycloak/AbstractPlugin.java create mode 100644 src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java create mode 100644 src/main/java/org/gcube/common/keycloak/KeycloakClient.java create mode 100644 src/main/java/org/gcube/common/keycloak/KeycloakClientException.java create mode 100644 src/main/java/org/gcube/common/keycloak/KeycloakClientFactory.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/AccessToken.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/AddressClaimSet.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/IDToken.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/JsonWebToken.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/ModelUtils.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/RefreshToken.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/TokenResponse.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/UserInfo.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/util/StringListMapDeserializer.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/util/StringOrArrayDeserializer.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/util/StringOrArraySerializer.java create mode 100644 src/main/java/org/gcube/common/keycloak/model/util/Time.java create mode 100644 src/test/java/org/gcube/common/keycloak/TestKeycloakClient.java create mode 100644 src/test/java/org/gcube/common/keycloak/TestModels.java create mode 100644 src/test/resources/log4j.xml create mode 100644 src/test/resources/oidc-token-response.json create mode 100644 src/test/resources/uma-access-token.json create mode 100644 src/test/resources/uma-refresh-token.json create mode 100644 src/test/resources/uma-token-response.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da1dad7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# OS stuff +################### +.DS_Store + +# Intellij +################### +.idea +*.iml + +# Eclipse # +########### +.project +.settings +.classpath +# reverting this as e.g. /distribution/feature-packs/server-feature-pack/src/main/resources/content/bin/ +# should not be ignored +#bin/ +.factorypath + + +# NetBeans # +############ +nbactions.xml +nb-configuration.xml +catalog.xml +nbproject + +# VS Code # +########### +*.code-workspace + +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Packages # +############ +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log + +# Maven # +######### +target + +# Maven shade +############# +*dependency-reduced-pom.xml + +# nodejs # +########## +# KEYCLOAK-5391: We will re-exclude node_modules when node_modules handling is worked out. +# For now, we keep our js libraries checked into GitHub, so we don't ignore. +#node_modules + +# testsuite # +############# +*offline-token.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f504ccc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +# Changelog for "keycloak-client" + +## [0.0.1-SNAPSHOT] +- First release (#21389) diff --git a/FUNDING.md b/FUNDING.md new file mode 100644 index 0000000..6fa9eac --- /dev/null +++ b/FUNDING.md @@ -0,0 +1,26 @@ +# Acknowledgments + +The projects leading to this software have received funding from a series of European Union programmes including: + +- the Sixth Framework Programme for Research and Technological Development + - [DILIGENT](https://cordis.europa.eu/project/id/004260) (grant no. 004260). +- the Seventh Framework Programme for research, technological development and demonstration + - [D4Science](https://cordis.europa.eu/project/id/212488) (grant no. 212488); + - [D4Science-II](https://cordis.europa.eu/project/id/239019) (grant no.239019); + - [ENVRI](https://cordis.europa.eu/project/id/283465) (grant no. 283465); + - [iMarine](https://cordis.europa.eu/project/id/283644) (grant no. 283644); + - [EUBrazilOpenBio](https://cordis.europa.eu/project/id/288754) (grant no. 288754). +- the H2020 research and innovation programme + - [SoBigData](https://cordis.europa.eu/project/id/654024) (grant no. 654024); + - [PARTHENOS](https://cordis.europa.eu/project/id/654119) (grant no. 654119); + - [EGI-Engage](https://cordis.europa.eu/project/id/654142) (grant no. 654142); + - [ENVRI PLUS](https://cordis.europa.eu/project/id/654182) (grant no. 654182); + - [BlueBRIDGE](https://cordis.europa.eu/project/id/675680) (grant no. 675680); + - [PerformFISH](https://cordis.europa.eu/project/id/727610) (grant no. 727610); + - [AGINFRA PLUS](https://cordis.europa.eu/project/id/731001) (grant no. 731001); + - [DESIRA](https://cordis.europa.eu/project/id/818194) (grant no. 818194); + - [ARIADNEplus](https://cordis.europa.eu/project/id/823914) (grant no. 823914); + - [RISIS 2](https://cordis.europa.eu/project/id/824091) (grant no. 824091); + - [EOSC-Pillar](https://cordis.europa.eu/project/id/857650) (grant no. 857650); + - [Blue Cloud](https://cordis.europa.eu/project/id/862409) (grant no. 862409); + - [SoBigData-PlusPlus](https://cordis.europa.eu/project/id/871042) (grant no. 871042); \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1932b4c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,311 @@ +#European Union Public Licence V.1.1 + +##*EUPL © the European Community 2007* + + +This **European Union Public Licence** (the **“EUPL”**) applies to the Work or Software +(as defined below) which is provided under the terms of this Licence. Any use of +the Work, other than as authorised under this Licence is prohibited (to the +extent such use is covered by a right of the copyright holder of the Work). + +The Original Work is provided under the terms of this Licence when the Licensor +(as defined below) has placed the following notice immediately following the +copyright notice for the Original Work: + +**Licensed under the EUPL V.1.1** + +or has expressed by any other mean his willingness to license under the EUPL. + + + +##1. Definitions + +In this Licence, the following terms have the following meaning: + +- The Licence: this Licence. + +- The Original Work or the Software: the software distributed and/or + communicated by the Licensor under this Licence, available as Source Code and + also as Executable Code as the case may be. + +- Derivative Works: the works or software that could be created by the Licensee, + based upon the Original Work or modifications thereof. This Licence does not + define the extent of modification or dependence on the Original Work required + in order to classify a work as a Derivative Work; this extent is determined by + copyright law applicable in the country mentioned in Article 15. + +- The Work: the Original Work and/or its Derivative Works. + +- The Source Code: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- The Executable Code: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- The Licensor: the natural or legal person that distributes and/or communicates + the Work under the Licence. + +- Contributor(s): any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- The Licensee or “You”: any natural or legal person who makes any usage of the + Software under the terms of the Licence. + +- Distribution and/or Communication: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, on-line or off-line, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + + + +##2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a world-wide, royalty-free, non-exclusive, +sub-licensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, reproduce the Work, modify +- the Original Work, and make Derivative Works based upon the Work, communicate +- to the public, including the right to make available or display the Work or +- copies thereof to the public and perform publicly, as the case may be, the +- Work, distribute the Work or copies thereof, lend and rent the Work or copies +- thereof, sub-license rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + + + +##3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute and/or communicate the Work. + + + +##4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Original Work or Software, of the exhaustion of those rights or of other +applicable limitations thereto. + + + +##5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: the Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes and/or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes and/or communicates copies of the +Original Works or Derivative Works based upon the Original Work, this +Distribution and/or Communication will be done under the terms of this Licence +or of a later version of this Licence unless the Original Work is expressly +distributed only under this version of the Licence. The Licensee (becoming +Licensor) cannot offer or impose any additional terms or conditions on the Work +or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes and/or Communicates Derivative +Works or copies thereof based upon both the Original Work and another work +licensed under a Compatible Licence, this Distribution and/or Communication can +be done under the terms of this Compatible Licence. For the sake of this clause, +“Compatible Licence” refers to the licences listed in the appendix attached to +this Licence. Should the Licensee’s obligations under the Compatible Licence +conflict with his/her obligations under this Licence, the obligations of the +Compatible Licence shall prevail. + +Provision of Source Code: When distributing and/or communicating copies of the +Work, the Licensee will provide a machine-readable copy of the Source Code or +indicate a repository where this Source will be easily and freely available for +as long as the Licensee continues to distribute and/or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + + + +##6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + + + +##7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +contributors. It is not a finished work and may therefore contain defects or +“bugs” inherent to this type of software development. + +For the above reason, the Work is provided under the Licence on an “as is” basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + + + +##8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such +damage. However, the Licensor will be liable under statutory product liability +laws as far such laws apply to the Work. + + + +##9. Additional agreements + +While distributing the Original Work or Derivative Works, You may choose to +conclude an additional agreement to offer, and charge a fee for, acceptance of +support, warranty, indemnity, or other liability obligations and/or services +consistent with this Licence. However, in accepting such obligations, You may +act only on your own behalf and on your sole responsibility, not on behalf of +the original Licensor or any other Contributor, and only if You agree to +indemnify, defend, and hold each Contributor harmless for any liability incurred +by, or claims asserted against such Contributor by the fact You have accepted +any such warranty or additional liability. + + + +##10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon “I agree” +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution and/or Communication by You of the Work or copies thereof. + + + +##11. Information to the public + +In case of any Distribution and/or Communication of the Work by means of +electronic communication by You (for example, by offering to download the Work +from a remote location) the distribution channel or media (for example, a +website) must at least provide to the public the information requested by the +applicable law regarding the Licensor, the Licence and the way it may be +accessible, concluded, stored and reproduced by the Licensee. + + + +##12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + + + +##13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work licensed hereunder. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed and/or reformed so as necessary to make +it valid and enforceable. + +The European Commission may publish other linguistic versions and/or new +versions of this Licence, so far this is required and reasonable, without +reducing the scope of the rights granted by the Licence. New versions of the +Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + + + +##14. Jurisdiction + +Any litigation resulting from the interpretation of this License, arising +between the European Commission, as a Licensor, and any Licensee, will be +subject to the jurisdiction of the Court of Justice of the European Communities, +as laid down in article 238 of the Treaty establishing the European Community. + +Any litigation arising between Parties, other than the European Commission, and +resulting from the interpretation of this License, will be subject to the +exclusive jurisdiction of the competent court where the Licensor resides or +conducts its primary business. + + + +##15. Applicable Law + +This Licence shall be governed by the law of the European Union country where +the Licensor resides or has his registered office. + +This licence shall be governed by the Belgian law if: + +- a litigation arises between the European Commission, as a Licensor, and any +- Licensee; the Licensor, other than the European Commission, has no residence +- or registered office inside a European Union country. + + +--- + + +##Appendix + + +**“Compatible Licences”** according to article 5 EUPL are: + + +- GNU General Public License (GNU GPL) v. 2 + +- Open Software License (OSL) v. 2.1, v. 3.0 + +- Common Public License v. 1.0 + +- Eclipse Public License v. 1.0 + +- Cecill v. 2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..282ab92 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Keycloak Client + +**Keycloak Clienty** provides the basic common classes for OpenId Connect (OIDC) integration and some helper abstract functions for the gCube framework integration + +## Structure of the project + +The source code is present in `src` folder. + +## Built With + +* [OpenJDK](https://openjdk.java.net/) - The JDK used +* [Maven](https://maven.apache.org/) - Dependency Management + +## Documentation + +To build the library JAR it is sufficient to type + + mvn clean package + +## Change log + +See [Releases](https://code-repo.d4science.org/gCubeSystem/authorization-client/releases). + +## Authors + +* **Mauro Mugnaini** ([Nubisware S.r.l.](http://www.nubisware.com)) + +## How to Cite this Software +[Intentionally left blank] + +## License + +This project is licensed under the EUPL V.1.1 License - see the [LICENSE.md](LICENSE.md) file for details. + +## About the gCube Framework +This software is part of the [gCubeFramework](https://www.gcube-system.org/ "gCubeFramework"): an +open-source software toolkit used for building and operating Hybrid Data +Infrastructures enabling the dynamic deployment of Virtual Research Environments +by favouring the realisation of reuse oriented policies. + +The projects leading to this software have received funding from a series of European Union programmes see [FUNDING.md](FUNDING.md) + +## Acknowledgments +[Intentionally left blank] \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..15cac09 --- /dev/null +++ b/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + + maven-parent + org.gcube.tools + 1.1.0 + + + + org.gcube.common + keycloak-client + 0.0.1-SNAPSHOT + + + + + org.gcube.distribution + gcube-bom + 2.0.2-SNAPSHOT + pom + import + + + + + + scm:git:https://code-repo.d4science.org/gCubeSystem/${project.artifactId}.git + scm:git:https://code-repo.d4science.org/gCubeSystem/${project.artifactId}.git + https://code-repo.d4science.org/gCubeSystem/${project.artifactId} + + + + + + org.slf4j + slf4j-api + + + + org.gcube.common + gcube-jackson-databind + + + + org.gcube.common + gcube-jackson-annotations + + + + org.gcube.common + gcube-jackson-core + + + + org.gcube.common + gxJRS + + + + org.gcube.core + common-fw-clients + + + + org.gcube.resources.discovery + ic-client + + + + org.slf4j + slf4j-log4j12 + 1.7.25 + test + + + + log4j + log4j + 1.2.16 + test + + + + junit + junit + 4.12 + test + + + + org.glassfish.jersey.core + jersey-common + test + + + + org.glassfish.jersey.core + jersey-client + test + + + + + + + \ No newline at end of file diff --git a/src/main/java/org/gcube/common/keycloak/AbstractPlugin.java b/src/main/java/org/gcube/common/keycloak/AbstractPlugin.java new file mode 100644 index 0000000..54c67ec --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/AbstractPlugin.java @@ -0,0 +1,25 @@ +package org.gcube.common.keycloak; + +import org.gcube.common.clients.fw.plugin.Plugin; + +public abstract class AbstractPlugin implements Plugin, KeycloakClient { + + public final String name; + + public AbstractPlugin(String name) { + this.name = name; + } + + public String serviceClass() { + return CATEGORY; + } + + public String serviceName() { + return NAME; + } + + public String name() { + return name; + } + +} \ No newline at end of file diff --git a/src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java b/src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java new file mode 100644 index 0000000..3bbbc60 --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/DefaultKeycloakClient.java @@ -0,0 +1,161 @@ +package org.gcube.common.keycloak; + +import static org.gcube.resources.discovery.icclient.ICFactory.clientFor; +import static org.gcube.resources.discovery.icclient.ICFactory.queryFor; + +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.gcube.common.gxrest.request.GXHTTPStringRequest; +import org.gcube.common.gxrest.response.inbound.GXInboundResponse; +import org.gcube.common.keycloak.model.TokenResponse; +import org.gcube.common.resources.gcore.ServiceEndpoint; +import org.gcube.common.resources.gcore.ServiceEndpoint.AccessPoint; +import org.gcube.common.scope.api.ScopeProvider; +import org.gcube.resources.discovery.client.api.DiscoveryClient; +import org.gcube.resources.discovery.client.queries.api.SimpleQuery; + +public class DefaultKeycloakClient implements KeycloakClient { + + private static final String PERMISSION_PARAMETER = "permission"; + private static final String GRANT_TYPE_PARAMETER = "grant_type"; + private static final String UMA_TOKEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:uma-ticket"; + private static final String AUDIENCE_PARAMETER = "audience"; + + @Override + public URL findTokenEndpointURL() throws KeycloakClientException { + logger.debug("Creating simple query"); + SimpleQuery query = queryFor(ServiceEndpoint.class); + query.addCondition( + String.format("$resource/Profile/Category/text() eq '%s'", CATEGORY)) + .addCondition(String.format("$resource/Profile/Name/text() eq '%s'", NAME)) + .setResult(String.format("$resource/Profile/AccessPoint[Description/text() eq '%s']", DESCRIPTION)); + + logger.debug("Creating client for AccessPoint"); + DiscoveryClient client = clientFor(AccessPoint.class); + + logger.trace("Submitting query: {}", query); + List accessPoints = client.submit(query); + + if (accessPoints.size() == 0) { + throw new KeycloakClientException("Service endpoint not found"); + } else if (accessPoints.size() > 1) { + throw new KeycloakClientException("Found more than one endpoint with query"); + } + String address = accessPoints.iterator().next().address(); + logger.debug("Found address: {}", address); + try { + return new URL(address); + } catch (MalformedURLException e) { + throw new KeycloakClientException("Cannot create URL from address: " + address, e); + } + } + + @Override + public TokenResponse queryUMAToken(String clientId, String clientSecret, List permissions) + throws KeycloakClientException { + + return queryUMAToken(clientId, clientSecret, ScopeProvider.instance.get(), permissions); + } + @Override + public TokenResponse queryUMAToken(String clientId, String clientSecret, String audience, + List permissions) throws KeycloakClientException { + + return queryUMAToken(findTokenEndpointURL(), clientId, clientSecret, audience, permissions); + } + + @Override + public TokenResponse queryUMAToken(URL tokenURL, String clientId, String clientSecret, String audience, + List permissions) throws KeycloakClientException { + + return queryUMAToken(tokenURL, + "Basic " + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes()), + audience, permissions); + } + + @Override + public TokenResponse queryUMAToken(URL tokenURL, String authorization, String audience, + List permissions) throws KeycloakClientException { + + logger.debug("Querying token from Keycloak server with URL: {}", tokenURL); + + Map> params = new HashMap<>(); + params.put(GRANT_TYPE_PARAMETER, Arrays.asList(UMA_TOKEN_GRANT_TYPE)); + + try { + params.put(AUDIENCE_PARAMETER, Arrays.asList(URLEncoder.encode(checkAudience(audience), "UTF-8"))); + } catch (UnsupportedEncodingException e) { + logger.error("Cannot URL encode 'audience'", e); + } + if (permissions != null && !permissions.isEmpty()) { + params.put( + PERMISSION_PARAMETER, permissions.stream().map(s -> { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return ""; + } + }).collect(Collectors.toList())); + } + + // Constructing request object + GXHTTPStringRequest request; + try { + String queryString = params.entrySet().stream() + .flatMap(p -> p.getValue().stream().map(v -> p.getKey() + "=" + v)) + .reduce((p1, p2) -> p1 + "&" + p2).orElse(""); + + request = GXHTTPStringRequest.newRequest(tokenURL.toString()) + .header("Content-Type", "application/x-www-form-urlencoded").withBody(queryString); + + request.isExternalCall(true); + if (authorization != null) { + logger.debug("Adding authorization header as: {}", authorization); + request = request.header("Authorization", authorization); + } + } catch (Exception e) { + throw new KeycloakClientException("Cannot construct the request object correctly", e); + } + + GXInboundResponse response; + try { + response = request.post(); + } catch (Exception e) { + throw new KeycloakClientException("Cannot send request correctly", e); + } + if (response.isSuccessResponse()) { + try { + return response.tryConvertStreamedContentFromJson(TokenResponse.class); + } catch (Exception e) { + throw new KeycloakClientException("Cannot construct token response object correctly", e); + } + } else { + throw KeycloakClientException.create("Unable to get token", response.getHTTPCode(), + response.getHeaderFields() + .getOrDefault("Content-Type", Collections.singletonList("unknown/unknown")).get(0), + response.getMessage()); + } + } + + private static String checkAudience(String audience) { + if (audience.startsWith("/")) { + try { + logger.trace("Audience was provided in non URL encoded form, encoding it"); + return URLEncoder.encode(audience, "UTF-8"); + } catch (UnsupportedEncodingException e) { + logger.error("Cannot URL encode 'audience'", e); + } + } + return audience; + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/KeycloakClient.java b/src/main/java/org/gcube/common/keycloak/KeycloakClient.java new file mode 100644 index 0000000..d0807ab --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/KeycloakClient.java @@ -0,0 +1,82 @@ +package org.gcube.common.keycloak; + +import java.net.URL; +import java.util.List; + +import org.gcube.common.keycloak.model.TokenResponse; +import org.gcube.common.scope.api.ScopeProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public interface KeycloakClient { + + Logger logger = LoggerFactory.getLogger(KeycloakClient.class); + + String CATEGORY = "Auth"; + String NAME = "IAM"; + String DESCRIPTION = "oidc-token endpoint"; + + /** + * Finds the keycloak endpoint {@link URL} discovering it in the current scope provided by {@link ScopeProvider} + * @return the keycloak endpoint URL in the current scope + * @throws KeycloakClientException if something goes wrong discovering the endpoint URL + */ + URL findTokenEndpointURL() throws KeycloakClientException; + + /** + * Queries an UMA token from the Keycloak server, by using provided authorization, for the given audience (context), + * in URLEncoded form or not, and optionally a list of permissions. + * + * @param tokenUrl the token endpoint {@link URL} of the OIDC server + * @param authorization the authorization to be set as header (e.g. a "Basic ...." auth or an encoded JWT access token preceded by the "Bearer " string) + * @param audience the audience (context) where to request the issuing of the ticket (URLEncoded) + * @param permissions a list of permissions, can be null + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryUMAToken(URL tokenURL, String authorization, String audience, List permissions) + throws KeycloakClientException; + + /** + * Queries an UMA token from the Keycloak server, by using provided clientId and client secret for the given audience + * (context), in URLEncoded form or not, and optionally a list of permissions. + * + * @param tokenURL the token endpoint {@link URL} of the Keycloak server + * @param clientId the client id + * @param clientSecret the client secret + * @param audience the audience (context) where to request the issuing of the ticket + * @param permissions a list of permissions, can be null + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryUMAToken(URL tokenURL, String clientId, String clientSecret, String audience, + List permissions) + throws KeycloakClientException; + + /** + * Queries an UMA token from the discovered Keycloak server in the current scope, by using provided clientId and client secret + * for the given audience (context), in URLEncoded form or not, and optionally a list of permissions. + * + * @param clientId the client id + * @param clientSecret the client secret + * @param audience the audience (context) where to request the issuing of the ticket + * @param permissions a list of permissions, can be null + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryUMAToken(String clientId, String clientSecret, String audience, List permissions) + throws KeycloakClientException; + + /** + * Queries an UMA token from the discovered Keycloak server in the current scope, by using provided clientId and client secret + * for the current scope audience (context), in URLEncoded form or not, and optionally a list of permissions. + * + * @param clientId the client id + * @param clientSecret the client secret + * @param permissions a list of permissions, can be null + * @return the issued token as {@link TokenResponse} object + * @throws KeycloakClientException if something goes wrong performing the query + */ + TokenResponse queryUMAToken(String clientId, String clientSecret, List permissions) + throws KeycloakClientException; +} \ No newline at end of file diff --git a/src/main/java/org/gcube/common/keycloak/KeycloakClientException.java b/src/main/java/org/gcube/common/keycloak/KeycloakClientException.java new file mode 100644 index 0000000..dd1ea1e --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/KeycloakClientException.java @@ -0,0 +1,70 @@ +package org.gcube.common.keycloak; + +public class KeycloakClientException extends Exception { + + private static final long serialVersionUID = -1615745541003534684L; + + private int status = -1; + private String contentType = null; + private String responseString = null; + + public static KeycloakClientException create(String message, int status, String contentType, + String textResponse) { + + return create(message, status, contentType, textResponse, null); + } + + public static KeycloakClientException create(String message, int status, String contentType, + String textResponse, Exception cause) { + + String exMessage = "[" + status + "] " + message + " (" + contentType + "): " + textResponse; + KeycloakClientException e = cause != null ? new KeycloakClientException(exMessage, cause) + : new KeycloakClientException(exMessage); + + e.setStatus(status); + e.setContentType(contentType); + e.setResponseString(textResponse); + return e; + } + + public KeycloakClientException() { + super(); + } + + public KeycloakClientException(String message) { + super(message); + } + + public KeycloakClientException(String message, Exception cause) { + super(message, cause); + } + + public void setStatus(int status) { + this.status = status; + } + + public int getStatus() { + return status; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getContentType() { + return contentType; + } + + public boolean hasJSONPayload() { + return getContentType().endsWith("json"); + } + + public void setResponseString(String responseString) { + this.responseString = responseString; + } + + public String getResponseString() { + return responseString; + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/KeycloakClientFactory.java b/src/main/java/org/gcube/common/keycloak/KeycloakClientFactory.java new file mode 100644 index 0000000..9c1d64c --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/KeycloakClientFactory.java @@ -0,0 +1,15 @@ +package org.gcube.common.keycloak; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class KeycloakClientFactory { + + protected static final Logger logger = LoggerFactory.getLogger(KeycloakClientFactory.class); + + public static KeycloakClient newInstance() { + logger.debug("Instantiating a new keycloak client instance"); + return new DefaultKeycloakClient(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/gcube/common/keycloak/model/AccessToken.java b/src/main/java/org/gcube/common/keycloak/model/AccessToken.java new file mode 100644 index 0000000..e9c79a4 --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/AccessToken.java @@ -0,0 +1,154 @@ +package org.gcube.common.keycloak.model; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.gcube.com.fasterxml.jackson.annotation.JsonIgnore; +import org.gcube.com.fasterxml.jackson.annotation.JsonProperty; + +public class AccessToken extends IDToken { + + private static final long serialVersionUID = 6364784008775737335L; + + public static class Access implements Serializable { + + private static final long serialVersionUID = 1634782115467850693L; + + @JsonProperty("roles") + protected Set roles; + + @JsonProperty("verify_caller") + protected Boolean verifyCaller; + + public Access() { + } + + public Access clone() { + Access access = new Access(); + access.verifyCaller = verifyCaller; + if (roles != null) { + access.roles = new HashSet<>(); + access.roles.addAll(roles); + } + return access; + } + + public Set getRoles() { + return roles; + } + + public Access roles(Set roles) { + this.roles = roles; + return this; + } + + @JsonIgnore + public boolean isUserInRole(String role) { + if (roles == null) + return false; + return roles.contains(role); + } + + public Access addRole(String role) { + if (roles == null) + roles = new HashSet<>(); + roles.add(role); + return this; + } + + public Boolean getVerifyCaller() { + return verifyCaller; + } + + public Access verifyCaller(Boolean required) { + this.verifyCaller = required; + return this; + } + } + + @JsonProperty("trusted-certs") + protected Set trustedCertificates; + + @JsonProperty("allowed-origins") + protected Set allowedOrigins; + + @JsonProperty("realm_access") + protected Access realmAccess; + + @JsonProperty("resource_access") + protected Map resourceAccess; + + @JsonProperty("scope") + protected String scope; + + @JsonIgnore + public Map getResourceAccess() { + return resourceAccess == null ? Collections.emptyMap() : resourceAccess; + } + + public void setResourceAccess(Map resourceAccess) { + this.resourceAccess = resourceAccess; + } + + public Access addAccess(String service) { + if (resourceAccess == null) { + resourceAccess = new HashMap<>(); + } + + Access access = resourceAccess.get(service); + if (access != null) + return access; + access = new Access(); + resourceAccess.put(service, access); + return access; + } + + @Override + public AccessToken id(String id) { + return (AccessToken) super.id(id); + } + + @Override + public AccessToken issuer(String issuer) { + return (AccessToken) super.issuer(issuer); + } + + @Override + public AccessToken subject(String subject) { + return (AccessToken) super.subject(subject); + } + + @Override + public AccessToken type(String type) { + return (AccessToken) super.type(type); + } + + public Set getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(Set allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public Access getRealmAccess() { + return realmAccess; + } + + public void setRealmAccess(Access realmAccess) { + this.realmAccess = realmAccess; + } + + public Set getTrustedCertificates() { + return trustedCertificates; + } + + public void setTrustedCertificates(Set trustedCertificates) { + this.trustedCertificates = trustedCertificates; + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/AddressClaimSet.java b/src/main/java/org/gcube/common/keycloak/model/AddressClaimSet.java new file mode 100644 index 0000000..ffe077d --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/AddressClaimSet.java @@ -0,0 +1,80 @@ +package org.gcube.common.keycloak.model; + +import org.gcube.com.fasterxml.jackson.annotation.JsonProperty; + +public class AddressClaimSet { + + public static final String FORMATTED = "formatted"; + public static final String STREET_ADDRESS = "street_address"; + public static final String LOCALITY = "locality"; + public static final String REGION = "region"; + public static final String POSTAL_CODE = "postal_code"; + public static final String COUNTRY = "country"; + + @JsonProperty(FORMATTED) + protected String formattedAddress; + + @JsonProperty(STREET_ADDRESS) + protected String streetAddress; + + @JsonProperty(LOCALITY) + protected String locality; + + @JsonProperty(REGION) + protected String region; + + @JsonProperty(POSTAL_CODE) + protected String postalCode; + + @JsonProperty(COUNTRY) + protected String country; + + public String getFormattedAddress() { + return this.formattedAddress; + } + + public void setFormattedAddress(String formattedAddress) { + this.formattedAddress = formattedAddress; + } + + public String getStreetAddress() { + return this.streetAddress; + } + + public void setStreetAddress(String streetAddress) { + this.streetAddress = streetAddress; + } + + public String getLocality() { + return this.locality; + } + + public void setLocality(String locality) { + this.locality = locality; + } + + public String getRegion() { + return this.region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getPostalCode() { + return this.postalCode; + } + + public void setPostalCode(String postalCode) { + this.postalCode = postalCode; + } + + public String getCountry() { + return this.country; + } + + public void setCountry(String country) { + this.country = country; + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/IDToken.java b/src/main/java/org/gcube/common/keycloak/model/IDToken.java new file mode 100644 index 0000000..85256cc --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/IDToken.java @@ -0,0 +1,337 @@ +package org.gcube.common.keycloak.model; + +import org.gcube.com.fasterxml.jackson.annotation.JsonProperty; + +public class IDToken extends JsonWebToken { + + private static final long serialVersionUID = 8406175387651749097L; + + public static final String NONCE = "nonce"; + public static final String AUTH_TIME = "auth_time"; + public static final String SESSION_STATE = "session_state"; + public static final String AT_HASH = "at_hash"; + public static final String C_HASH = "c_hash"; + public static final String NAME = "name"; + public static final String GIVEN_NAME = "given_name"; + public static final String FAMILY_NAME = "family_name"; + public static final String MIDDLE_NAME = "middle_name"; + public static final String NICKNAME = "nickname"; + public static final String PREFERRED_USERNAME = "preferred_username"; + public static final String PROFILE = "profile"; + public static final String PICTURE = "picture"; + public static final String WEBSITE = "website"; + public static final String EMAIL = "email"; + public static final String EMAIL_VERIFIED = "email_verified"; + public static final String GENDER = "gender"; + public static final String BIRTHDATE = "birthdate"; + public static final String ZONEINFO = "zoneinfo"; + public static final String LOCALE = "locale"; + public static final String PHONE_NUMBER = "phone_number"; + public static final String PHONE_NUMBER_VERIFIED = "phone_number_verified"; + public static final String ADDRESS = "address"; + public static final String UPDATED_AT = "updated_at"; + public static final String CLAIMS_LOCALES = "claims_locales"; + public static final String ACR = "acr"; + + public static final String S_HASH = "s_hash"; + + public IDToken() { + } + + @JsonProperty(NONCE) + protected String nonce; + + protected Long auth_time; + + @JsonProperty(SESSION_STATE) + protected String sessionState; + + @JsonProperty(AT_HASH) + protected String accessTokenHash; + + @JsonProperty(C_HASH) + protected String codeHash; + + @JsonProperty(NAME) + protected String name; + + @JsonProperty(GIVEN_NAME) + protected String givenName; + + @JsonProperty(FAMILY_NAME) + protected String familyName; + + @JsonProperty(MIDDLE_NAME) + protected String middleName; + + @JsonProperty(NICKNAME) + protected String nickName; + + @JsonProperty(PREFERRED_USERNAME) + protected String preferredUsername; + + @JsonProperty(PROFILE) + protected String profile; + + @JsonProperty(PICTURE) + protected String picture; + + @JsonProperty(WEBSITE) + protected String website; + + @JsonProperty(EMAIL) + protected String email; + + @JsonProperty(EMAIL_VERIFIED) + protected Boolean emailVerified; + + @JsonProperty(GENDER) + protected String gender; + + @JsonProperty(BIRTHDATE) + protected String birthdate; + + @JsonProperty(ZONEINFO) + protected String zoneinfo; + + @JsonProperty(LOCALE) + protected String locale; + + @JsonProperty(PHONE_NUMBER) + protected String phoneNumber; + + @JsonProperty(PHONE_NUMBER_VERIFIED) + protected Boolean phoneNumberVerified; + + @JsonProperty(ADDRESS) + protected AddressClaimSet address; + + @JsonProperty(UPDATED_AT) + protected Long updatedAt; + + @JsonProperty(CLAIMS_LOCALES) + protected String claimsLocales; + + @JsonProperty(ACR) + protected String acr; + + @JsonProperty(S_HASH) + protected String stateHash; + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public Long getAuth_time() { + return auth_time; + } + + public void setAuth_time(Long auth_time) { + this.auth_time = auth_time; + } + + public String getSessionState() { + return sessionState; + } + + public void setSessionState(String sessionState) { + this.sessionState = sessionState; + } + + public String getAccessTokenHash() { + return accessTokenHash; + } + + public void setAccessTokenHash(String accessTokenHash) { + this.accessTokenHash = accessTokenHash; + } + + public String getCodeHash() { + return codeHash; + } + + public void setCodeHash(String codeHash) { + this.codeHash = codeHash; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getGivenName() { + return this.givenName; + } + + public void setGivenName(String givenName) { + this.givenName = givenName; + } + + public String getFamilyName() { + return this.familyName; + } + + public void setFamilyName(String familyName) { + this.familyName = familyName; + } + + public String getMiddleName() { + return this.middleName; + } + + public void setMiddleName(String middleName) { + this.middleName = middleName; + } + + public String getNickName() { + return this.nickName; + } + + public void setNickName(String nickName) { + this.nickName = nickName; + } + + public String getPreferredUsername() { + return this.preferredUsername; + } + + public void setPreferredUsername(String preferredUsername) { + this.preferredUsername = preferredUsername; + } + + public String getProfile() { + return this.profile; + } + + public void setProfile(String profile) { + this.profile = profile; + } + + public String getPicture() { + return this.picture; + } + + public void setPicture(String picture) { + this.picture = picture; + } + + public String getWebsite() { + return this.website; + } + + public void setWebsite(String website) { + this.website = website; + } + + public String getEmail() { + return this.email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean getEmailVerified() { + return this.emailVerified; + } + + public void setEmailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public String getGender() { + return this.gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getBirthdate() { + return this.birthdate; + } + + public void setBirthdate(String birthdate) { + this.birthdate = birthdate; + } + + public String getZoneinfo() { + return this.zoneinfo; + } + + public void setZoneinfo(String zoneinfo) { + this.zoneinfo = zoneinfo; + } + + public String getLocale() { + return this.locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + public String getPhoneNumber() { + return this.phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public Boolean getPhoneNumberVerified() { + return this.phoneNumberVerified; + } + + public void setPhoneNumberVerified(Boolean phoneNumberVerified) { + this.phoneNumberVerified = phoneNumberVerified; + } + + public AddressClaimSet getAddress() { + return address; + } + + public void setAddress(AddressClaimSet address) { + this.address = address; + } + + public Long getUpdatedAt() { + return this.updatedAt; + } + + public void setUpdatedAt(Long updatedAt) { + this.updatedAt = updatedAt; + } + + public String getClaimsLocales() { + return this.claimsLocales; + } + + public void setClaimsLocales(String claimsLocales) { + this.claimsLocales = claimsLocales; + } + + public String getAcr() { + return acr; + } + + public void setAcr(String acr) { + this.acr = acr; + } + + public String getStateHash() { + return stateHash; + } + + public void setStateHash(String stateHash) { + this.stateHash = stateHash; + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/JsonWebToken.java b/src/main/java/org/gcube/common/keycloak/model/JsonWebToken.java new file mode 100644 index 0000000..2b1458a --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/JsonWebToken.java @@ -0,0 +1,211 @@ +package org.gcube.common.keycloak.model; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.gcube.com.fasterxml.jackson.annotation.JsonAnyGetter; +import org.gcube.com.fasterxml.jackson.annotation.JsonAnySetter; +import org.gcube.com.fasterxml.jackson.annotation.JsonIgnore; +import org.gcube.com.fasterxml.jackson.annotation.JsonProperty; +import org.gcube.com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.gcube.com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.gcube.common.keycloak.model.util.StringOrArrayDeserializer; +import org.gcube.common.keycloak.model.util.StringOrArraySerializer; +import org.gcube.common.keycloak.model.util.Time; + +public class JsonWebToken implements Serializable { + + private static final long serialVersionUID = -8136409077130940942L; + + @JsonProperty("jti") + protected String id; + + protected Long exp; + protected Long nbf; + protected Long iat; + + @JsonProperty("iss") + protected String issuer; + @JsonProperty("aud") + @JsonSerialize(using = StringOrArraySerializer.class) + @JsonDeserialize(using = StringOrArrayDeserializer.class) + protected String[] audience; + @JsonProperty("sub") + protected String subject; + @JsonProperty("typ") + protected String type; + @JsonProperty("azp") + public String issuedFor; + protected Map otherClaims = new HashMap<>(); + + public String getId() { + return id; + } + + public JsonWebToken id(String id) { + this.id = id; + return this; + } + + public Long getExp() { + return exp; + } + + public JsonWebToken exp(Long exp) { + this.exp = exp; + return this; + } + + @JsonIgnore + public boolean isExpired() { + return exp != null && exp != 0 ? Time.currentTime() > exp : false; + } + + public Long getNbf() { + return nbf; + } + + public JsonWebToken nbf(Long nbf) { + this.nbf = nbf; + return this; + } + + @JsonIgnore + public boolean isNotBefore(int allowedTimeSkew) { + return nbf != null ? Time.currentTime() + allowedTimeSkew >= nbf : true; + } + + /** + * Tests that the token is not expired and is not-before. + * + * @return + */ + @JsonIgnore + public boolean isActive() { + return isActive(0); + } + + @JsonIgnore + public boolean isActive(int allowedTimeSkew) { + return !isExpired() && isNotBefore(allowedTimeSkew); + } + + public Long getIat() { + return iat; + } + + /** + * Set issuedAt to the current time + */ + @JsonIgnore + public JsonWebToken issuedNow() { + iat = Long.valueOf(Time.currentTime()); + return this; + } + + public JsonWebToken iat(Long iat) { + this.iat = iat; + return this; + } + + public String getIssuer() { + return issuer; + } + + public JsonWebToken issuer(String issuer) { + this.issuer = issuer; + return this; + } + + @JsonIgnore + public String[] getAudience() { + return audience; + } + + public boolean hasAudience(String audience) { + if (this.audience == null) + return false; + for (String a : this.audience) { + if (a.equals(audience)) { + return true; + } + } + return false; + } + + public JsonWebToken audience(String... audience) { + this.audience = audience; + return this; + } + + public JsonWebToken addAudience(String audience) { + if (this.audience == null) { + this.audience = new String[] { audience }; + } else { + // Check if audience is already there + for (String aud : this.audience) { + if (audience.equals(aud)) { + return this; + } + } + + String[] newAudience = Arrays.copyOf(this.audience, this.audience.length + 1); + newAudience[this.audience.length] = audience; + this.audience = newAudience; + } + return this; + } + + public String getSubject() { + return subject; + } + + public JsonWebToken subject(String subject) { + this.subject = subject; + return this; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getType() { + return type; + } + + public JsonWebToken type(String type) { + this.type = type; + return this; + } + + /** + * OAuth client the token was issued for. + * + * @return + */ + public String getIssuedFor() { + return issuedFor; + } + + public JsonWebToken issuedFor(String issuedFor) { + this.issuedFor = issuedFor; + return this; + } + + /** + * This is a map of any other claims and data that might be in the IDToken. Could be custom claims set up by the auth server + * + * @return + */ + @JsonAnyGetter + public Map getOtherClaims() { + return otherClaims; + } + + @JsonAnySetter + public void setOtherClaims(String name, Object value) { + otherClaims.put(name, value); + } +} diff --git a/src/main/java/org/gcube/common/keycloak/model/ModelUtils.java b/src/main/java/org/gcube/common/keycloak/model/ModelUtils.java new file mode 100644 index 0000000..c8fabb0 --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/ModelUtils.java @@ -0,0 +1,97 @@ +package org.gcube.common.keycloak.model; + +import java.util.Base64; + +import org.gcube.com.fasterxml.jackson.annotation.JsonInclude.Include; +import org.gcube.com.fasterxml.jackson.core.JsonProcessingException; +import org.gcube.com.fasterxml.jackson.databind.ObjectMapper; +import org.gcube.com.fasterxml.jackson.databind.ObjectWriter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ModelUtils { + + protected static final Logger logger = LoggerFactory.getLogger(ModelUtils.class); + + private static final ObjectMapper mapper = new ObjectMapper(); + + static { + mapper.setSerializationInclusion(Include.NON_NULL); + } + + public static String toJSONString(Object object) { + return toJSONString(object, false); + } + + public static String toJSONString(Object object, boolean prettyPrint) { + ObjectWriter writer = prettyPrint ? mapper.writerWithDefaultPrettyPrinter() : mapper.writer(); + try { + return writer.writeValueAsString(object); + } catch (JsonProcessingException e) { + logger.error("Cannot pretty print object", e); + return null; + } + } + + private static byte[] getDecodedPayload(String value) { + return getBase64Decoded(getEncodedPayload(value)); + } + + public static String getAccessTokenPayloadStringFrom(TokenResponse tokenResponse) throws Exception { + return getAccessTokenPayloadStringFrom(tokenResponse, true); + } + + public static String getAccessTokenPayloadStringFrom(TokenResponse tokenResponse, boolean prettyPrint) throws Exception { + return toJSONString(getAccessTokenFrom(tokenResponse, Object.class), prettyPrint); + } + + public static AccessToken getAccessTokenFrom(TokenResponse tokenResponse) throws Exception { + return getAccessTokenFrom(tokenResponse, RefreshToken.class); + } + + private static T getAccessTokenFrom(TokenResponse tokenResponse, Class clazz) throws Exception { + return mapper.readValue(getDecodedPayload(tokenResponse.getAccessToken()), clazz); + } + + public static String getRefreshTokenPayloadStringFrom(TokenResponse tokenResponse) throws Exception { + return getRefreshTokenPayloadStringFrom(tokenResponse, true); + } + + public static String getRefreshTokenPayloadStringFrom(TokenResponse tokenResponse, boolean prettyPrint) throws Exception { + return toJSONString(getRefreshTokenFrom(tokenResponse, Object.class), prettyPrint); + } + + public static RefreshToken getRefreshTokenFrom(TokenResponse tokenResponse) throws Exception { + return getRefreshTokenFrom(tokenResponse, RefreshToken.class); + } + + private static T getRefreshTokenFrom(TokenResponse tokenResponse, Class clazz) throws Exception { + return mapper.readValue(getDecodedPayload(tokenResponse.getRefreshToken()), clazz); + } + + protected static byte[] getBase64Decoded(String string) { + return Base64.getDecoder().decode(string); + } + + protected static String splitAndGet(String encodedJWT, int index) { + String[] split = encodedJWT.split("\\."); + if (split.length == 3) { + return split[index]; + } else { + return null; + } + } + + public static String getEncodedHeader(String encodedJWT) { + return splitAndGet(encodedJWT, 0); + } + + public static String getEncodedPayload(String encodedJWT) { + return splitAndGet(encodedJWT, 1); + } + + public static String getEncodedSignature(String encodedJWT) { + return splitAndGet(encodedJWT, 2); + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/RefreshToken.java b/src/main/java/org/gcube/common/keycloak/model/RefreshToken.java new file mode 100644 index 0000000..2ee8115 --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/RefreshToken.java @@ -0,0 +1,21 @@ +package org.gcube.common.keycloak.model; + +public class RefreshToken extends AccessToken { + + private static final long serialVersionUID = 2646534143077862960L; + + public RefreshToken() { + } + + public RefreshToken(AccessToken token) { + super(); + this.issuer = token.issuer; + this.subject = token.subject; + this.issuedFor = token.issuedFor; + this.sessionState = token.sessionState; + this.nonce = token.nonce; + this.audience = new String[] { token.issuer }; + this.scope = token.scope; + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/TokenResponse.java b/src/main/java/org/gcube/common/keycloak/model/TokenResponse.java new file mode 100644 index 0000000..3772057 --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/TokenResponse.java @@ -0,0 +1,133 @@ +package org.gcube.common.keycloak.model; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +import org.gcube.com.fasterxml.jackson.annotation.JsonAnyGetter; +import org.gcube.com.fasterxml.jackson.annotation.JsonAnySetter; +import org.gcube.com.fasterxml.jackson.annotation.JsonProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TokenResponse implements Serializable { + + protected static Logger logger = LoggerFactory.getLogger(TokenResponse.class); + + private static final long serialVersionUID = -7063122428186284827L; + + @JsonProperty("access_token") + protected String accessToken; + + @JsonProperty("expires_in") + protected long expiresIn; + + @JsonProperty("refresh_expires_in") + protected long refreshExpiresIn; + + @JsonProperty("refresh_token") + protected String refreshToken; + + @JsonProperty("token_type") + protected String tokenType; + + @JsonProperty("id_token") + protected String idToken; + + @JsonProperty("not-before-policy") + protected int notBeforePolicy; + + @JsonProperty("session_state") + protected String sessionState; + + protected Map otherClaims = new HashMap<>(); + + @JsonProperty("scope") + protected String scope; + + public TokenResponse() { + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getAccessToken() { + return accessToken; + } + + public void setSccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(long expiresIn) { + this.expiresIn = expiresIn; + } + + public long getRefreshExpiresIn() { + return refreshExpiresIn; + } + + public void setRefreshExpiresIn(long refreshExpiresIn) { + this.refreshExpiresIn = refreshExpiresIn; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getIdToken() { + return idToken; + } + + public void setIdToken(String idToken) { + this.idToken = idToken; + } + + public int getNotBeforePolicy() { + return notBeforePolicy; + } + + public void setNotBeforePolicy(int notBeforePolicy) { + this.notBeforePolicy = notBeforePolicy; + } + + public String getSessionState() { + return sessionState; + } + + public void setSessionState(String sessionState) { + this.sessionState = sessionState; + } + + @JsonAnyGetter + public Map getOtherClaims() { + return otherClaims; + } + + @JsonAnySetter + public void setOtherClaims(String name, Object value) { + otherClaims.put(name, value); + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/UserInfo.java b/src/main/java/org/gcube/common/keycloak/model/UserInfo.java new file mode 100644 index 0000000..b3bfb9d --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/UserInfo.java @@ -0,0 +1,309 @@ +package org.gcube.common.keycloak.model; + +import java.util.HashMap; +import java.util.Map; + +import org.gcube.com.fasterxml.jackson.annotation.JsonAnyGetter; +import org.gcube.com.fasterxml.jackson.annotation.JsonAnySetter; +import org.gcube.com.fasterxml.jackson.annotation.JsonIgnore; +import org.gcube.com.fasterxml.jackson.annotation.JsonProperty; +import org.gcube.com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.gcube.com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.gcube.common.keycloak.model.util.StringOrArrayDeserializer; +import org.gcube.common.keycloak.model.util.StringOrArraySerializer; + +/** + * @author pedroigor + */ +public class UserInfo { + + // Should be in signed UserInfo response + @JsonProperty("iss") + protected String issuer; + @JsonProperty("aud") + @JsonSerialize(using = StringOrArraySerializer.class) + @JsonDeserialize(using = StringOrArrayDeserializer.class) + protected String[] audience; + + @JsonProperty("sub") + protected String sub; + + @JsonProperty("name") + protected String name; + + @JsonProperty("given_name") + protected String givenName; + + @JsonProperty("family_name") + protected String familyName; + + @JsonProperty("middle_name") + protected String middleName; + + @JsonProperty("nickname") + protected String nickName; + + @JsonProperty("preferred_username") + protected String preferredUsername; + + @JsonProperty("profile") + protected String profile; + + @JsonProperty("picture") + protected String picture; + + @JsonProperty("website") + protected String website; + + @JsonProperty("email") + protected String email; + + @JsonProperty("email_verified") + protected Boolean emailVerified; + + @JsonProperty("gender") + protected String gender; + + @JsonProperty("birthdate") + protected String birthdate; + + @JsonProperty("zoneinfo") + protected String zoneinfo; + + @JsonProperty("locale") + protected String locale; + + @JsonProperty("phone_number") + protected String phoneNumber; + + @JsonProperty("phone_number_verified") + protected Boolean phoneNumberVerified; + + @JsonProperty("address") + protected AddressClaimSet address; + + @JsonProperty("updated_at") + protected Long updatedAt; + + @JsonProperty("claims_locales") + protected String claimsLocales; + + protected Map otherClaims = new HashMap<>(); + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + @JsonIgnore + public String[] getAudience() { + return audience; + } + + public boolean hasAudience(String audience) { + for (String a : this.audience) { + if (a.equals(audience)) { + return true; + } + } + return false; + } + + public void setAudience(String... audience) { + this.audience = audience; + } + + public String getSubject() { + return this.sub; + } + + public void setSubject(String subject) { + this.sub = subject; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getGivenName() { + return this.givenName; + } + + public void setGivenName(String givenName) { + this.givenName = givenName; + } + + public String getFamilyName() { + return this.familyName; + } + + public void setFamilyName(String familyName) { + this.familyName = familyName; + } + + public String getMiddleName() { + return this.middleName; + } + + public void setMiddleName(String middleName) { + this.middleName = middleName; + } + + public String getNickName() { + return this.nickName; + } + + public void setNickName(String nickName) { + this.nickName = nickName; + } + + public String getPreferredUsername() { + return this.preferredUsername; + } + + public void setPreferredUsername(String preferredUsername) { + this.preferredUsername = preferredUsername; + } + + public String getProfile() { + return this.profile; + } + + public void setProfile(String profile) { + this.profile = profile; + } + + public String getPicture() { + return this.picture; + } + + public void setPicture(String picture) { + this.picture = picture; + } + + public String getWebsite() { + return this.website; + } + + public void setWebsite(String website) { + this.website = website; + } + + public String getEmail() { + return this.email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean getEmailVerified() { + return this.emailVerified; + } + + public void setEmailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public String getGender() { + return this.gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getBirthdate() { + return this.birthdate; + } + + public void setBirthdate(String birthdate) { + this.birthdate = birthdate; + } + + public String getZoneinfo() { + return this.zoneinfo; + } + + public void setZoneinfo(String zoneinfo) { + this.zoneinfo = zoneinfo; + } + + public String getLocale() { + return this.locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + public String getPhoneNumber() { + return this.phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public Boolean getPhoneNumberVerified() { + return this.phoneNumberVerified; + } + + public void setPhoneNumberVerified(Boolean phoneNumberVerified) { + this.phoneNumberVerified = phoneNumberVerified; + } + + public AddressClaimSet getAddress() { + return address; + } + + public void setAddress(AddressClaimSet address) { + this.address = address; + } + + public Long getUpdatedAt() { + return this.updatedAt; + } + + public void setUpdatedAt(Long updatedAt) { + this.updatedAt = updatedAt; + } + + public String getSub() { + return this.sub; + } + + public void setSub(String sub) { + this.sub = sub; + } + + public String getClaimsLocales() { + return this.claimsLocales; + } + + public void setClaimsLocales(String claimsLocales) { + this.claimsLocales = claimsLocales; + } + + /** + * This is a map of any other claims and data that might be in the UserInfo. Could be custom claims set up by the auth server + * + * @return + */ + @JsonAnyGetter + public Map getOtherClaims() { + return otherClaims; + } + + @JsonAnySetter + public void setOtherClaims(String name, Object value) { + otherClaims.put(name, value); + } +} diff --git a/src/main/java/org/gcube/common/keycloak/model/util/StringListMapDeserializer.java b/src/main/java/org/gcube/common/keycloak/model/util/StringListMapDeserializer.java new file mode 100644 index 0000000..d399eff --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/util/StringListMapDeserializer.java @@ -0,0 +1,41 @@ +package org.gcube.common.keycloak.model.util; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.gcube.com.fasterxml.jackson.core.JsonParser; +import org.gcube.com.fasterxml.jackson.databind.DeserializationContext; +import org.gcube.com.fasterxml.jackson.databind.JsonDeserializer; +import org.gcube.com.fasterxml.jackson.databind.JsonNode; +import org.gcube.com.fasterxml.jackson.databind.node.ArrayNode; + +public class StringListMapDeserializer extends JsonDeserializer { + + @Override + public Object deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + JsonNode jsonNode = jsonParser.readValueAsTree(); + Iterator> itr = jsonNode.fields(); + Map> map = new HashMap<>(); + while (itr.hasNext()) { + Map.Entry e = itr.next(); + List values = new LinkedList<>(); + if (!e.getValue().isArray()) { + values.add((e.getValue().isNull()) ? null : e.getValue().asText()); + } else { + ArrayNode a = (ArrayNode) e.getValue(); + Iterator vitr = a.elements(); + while (vitr.hasNext()) { + JsonNode node = vitr.next(); + values.add((node.isNull() ? null : node.asText())); + } + } + map.put(e.getKey(), values); + } + return map; + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/util/StringOrArrayDeserializer.java b/src/main/java/org/gcube/common/keycloak/model/util/StringOrArrayDeserializer.java new file mode 100644 index 0000000..6eb1450 --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/util/StringOrArrayDeserializer.java @@ -0,0 +1,29 @@ +package org.gcube.common.keycloak.model.util; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; + +import org.gcube.com.fasterxml.jackson.core.JsonParser; +import org.gcube.com.fasterxml.jackson.databind.DeserializationContext; +import org.gcube.com.fasterxml.jackson.databind.JsonDeserializer; +import org.gcube.com.fasterxml.jackson.databind.JsonNode; + +public class StringOrArrayDeserializer extends JsonDeserializer { + + @Override + public Object deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + JsonNode jsonNode = jsonParser.readValueAsTree(); + if (jsonNode.isArray()) { + ArrayList a = new ArrayList<>(1); + Iterator itr = jsonNode.iterator(); + while (itr.hasNext()) { + a.add(itr.next().textValue()); + } + return a.toArray(new String[a.size()]); + } else { + return new String[] { jsonNode.textValue() }; + } + } + +} diff --git a/src/main/java/org/gcube/common/keycloak/model/util/StringOrArraySerializer.java b/src/main/java/org/gcube/common/keycloak/model/util/StringOrArraySerializer.java new file mode 100644 index 0000000..386ec5d --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/util/StringOrArraySerializer.java @@ -0,0 +1,25 @@ +package org.gcube.common.keycloak.model.util; + +import java.io.IOException; + +import org.gcube.com.fasterxml.jackson.core.JsonGenerator; +import org.gcube.com.fasterxml.jackson.databind.JsonSerializer; +import org.gcube.com.fasterxml.jackson.databind.SerializerProvider; + +public class StringOrArraySerializer extends JsonSerializer { + @Override + public void serialize(Object o, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + String[] array = (String[]) o; + if (array == null) { + jsonGenerator.writeNull(); + } else if (array.length == 1) { + jsonGenerator.writeString(array[0]); + } else { + jsonGenerator.writeStartArray(); + for (String s : array) { + jsonGenerator.writeString(s); + } + jsonGenerator.writeEndArray(); + } + } +} diff --git a/src/main/java/org/gcube/common/keycloak/model/util/Time.java b/src/main/java/org/gcube/common/keycloak/model/util/Time.java new file mode 100644 index 0000000..1101d1a --- /dev/null +++ b/src/main/java/org/gcube/common/keycloak/model/util/Time.java @@ -0,0 +1,67 @@ +package org.gcube.common.keycloak.model.util; + +import java.util.Date; + +public class Time { + + private static int offset; + + /** + * Returns current time in seconds adjusted by adding {@link #offset) seconds. + * @return see description + */ + public static int currentTime() { + return ((int) (System.currentTimeMillis() / 1000)) + offset; + } + + /** + * Returns current time in milliseconds adjusted by adding {@link #offset) seconds. + * @return see description + */ + public static long currentTimeMillis() { + return System.currentTimeMillis() + (offset * 1000L); + } + + /** + * Returns {@link Date} object, its value set to time + * @param time Time in milliseconds since the epoch + * @return see description + */ + public static Date toDate(int time) { + return new Date(time * 1000L); + } + + /** + * Returns {@link Date} object, its value set to time + * @param time Time in milliseconds since the epoch + * @return see description + */ + public static Date toDate(long time) { + return new Date(time); + } + + /** + * Returns time in milliseconds for a time in seconds. No adjustment is made to the parameter. + * @param time Time in seconds since the epoch + * @return Time in milliseconds + */ + public static long toMillis(int time) { + return time * 1000L; + } + + /** + * @return Time offset in seconds that will be added to {@link #currentTime()} and {@link #currentTimeMillis()}. + */ + public static int getOffset() { + return offset; + } + + /** + * Sets time offset in seconds that will be added to {@link #currentTime()} and {@link #currentTimeMillis()}. + * @param offset Offset (in seconds) + */ + public static void setOffset(int offset) { + Time.offset = offset; + } + +} diff --git a/src/test/java/org/gcube/common/keycloak/TestKeycloakClient.java b/src/test/java/org/gcube/common/keycloak/TestKeycloakClient.java new file mode 100644 index 0000000..a961663 --- /dev/null +++ b/src/test/java/org/gcube/common/keycloak/TestKeycloakClient.java @@ -0,0 +1,69 @@ +package org.gcube.common.keycloak; + +import java.net.URL; + +import org.gcube.common.keycloak.model.ModelUtils; +import org.gcube.common.keycloak.model.TokenResponse; +import org.gcube.common.scope.api.ScopeProvider; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TestKeycloakClient { + + protected static final Logger logger = LoggerFactory.getLogger(TestKeycloakClient.class); + + private static final String DEV_ENDPOINT = "http://accounts.dev.d4science.org/auth/realms/d4science/protocol/openid-connect/token"; + private static final String CLIENT_ID = "keycloak-client"; + private static final String CLIENT_SECRET = "38f76152-2b7c-418f-9b67-66f4cc2f401e"; + private static final String TEST_AUDIENCE = "conductor-server"; + + @Before + public void setUp() throws Exception { + ScopeProvider.instance.set("/gcube"); + } + + @After + public void tearDown() throws Exception { + } + + @Test + public void testEndpointDiscovery() throws Exception { + logger.info("Start testing Keycloak endpoint discovery..."); + URL url = KeycloakClientFactory.newInstance().findTokenEndpointURL(); + Assert.assertNotNull(url); + Assert.assertTrue(url.getProtocol().equals("https")); + } + + @Test + public void testQueryUMATokenWithDiscoveryInCurrentScope() throws Exception { + logger.info("Start testing query UMA token from Keycloak with endpoint discovery and current scope..."); + TokenResponse tr = KeycloakClientFactory.newInstance().queryUMAToken(CLIENT_ID, CLIENT_SECRET, null); + TestModels.checkTokenResponse(tr); + TestModels.checkAccessToken(ModelUtils.getAccessTokenFrom(tr), "service-account-" + CLIENT_ID); + } + + @Test + public void testQueryUMATokenWithDiscovery() throws Exception { + logger.info("Start testing query UMA token from Keycloak with endpoint discovery..."); + TokenResponse tr = KeycloakClientFactory.newInstance().queryUMAToken(CLIENT_ID, CLIENT_SECRET, TEST_AUDIENCE, + null); + + TestModels.checkTokenResponse(tr); + TestModels.checkAccessToken(ModelUtils.getAccessTokenFrom(tr), "service-account-" + CLIENT_ID); + } + + @Test + public void testQueryUMAToken() throws Exception { + logger.info("Start testing query UMA token from Keycloak with URL..."); + TokenResponse tr = KeycloakClientFactory.newInstance() + .queryUMAToken(new URL(DEV_ENDPOINT), CLIENT_ID, CLIENT_SECRET, TEST_AUDIENCE, null); + + TestModels.checkTokenResponse(tr); + TestModels.checkAccessToken(ModelUtils.getAccessTokenFrom(tr), "service-account-" + CLIENT_ID); + } + +} diff --git a/src/test/java/org/gcube/common/keycloak/TestModels.java b/src/test/java/org/gcube/common/keycloak/TestModels.java new file mode 100644 index 0000000..511b3bc --- /dev/null +++ b/src/test/java/org/gcube/common/keycloak/TestModels.java @@ -0,0 +1,89 @@ +package org.gcube.common.keycloak; + +import java.io.File; + +import org.gcube.com.fasterxml.jackson.databind.ObjectMapper; +import org.gcube.common.keycloak.model.TokenResponse; +import org.gcube.common.keycloak.model.AccessToken; +import org.gcube.common.keycloak.model.ModelUtils; +import org.gcube.common.keycloak.model.RefreshToken; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TestModels { + + protected static final Logger logger = LoggerFactory.getLogger(TestModels.class); + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + } + + @Test + public void testTokenResponseForOIDC() throws Exception { + logger.info("Start testing OIDC token response object binding..."); + TokenResponse tr = new ObjectMapper().readValue(new File("src/test/resources/oidc-token-response.json"), + TokenResponse.class); + + logger.debug("OIDC token response:\n{}", ModelUtils.toJSONString(tr, true)); + checkTokenResponse(tr); + + } + + @Test + public void testTokenResponseForUMA() throws Exception { + logger.info("Start testing UMA token response object binding..."); + TokenResponse tr = new ObjectMapper().readValue(new File("src/test/resources/uma-token-response.json"), + TokenResponse.class); + + logger.debug("UMA token response:\n{}", ModelUtils.toJSONString(tr, true)); + checkTokenResponse(tr); + } + + @Test + public void testUMAAccessToken() throws Exception { + logger.info("Start testing access token object binding..."); + AccessToken at = new ObjectMapper().readValue(new File("src/test/resources/uma-access-token.json"), + AccessToken.class); + + checkAccessToken(at, null); + } + + @Test + public void testUMARefreshToken() throws Exception { + logger.info("Start testing refresh token object binding..."); + RefreshToken rt = new ObjectMapper().readValue(new File("src/test/resources/uma-refresh-token.json"), + RefreshToken.class); + + checkRefreshToken(rt); + } + + public static void checkTokenResponse(TokenResponse tr) throws Exception { + Assert.assertNotNull(tr); + Assert.assertEquals("bearer", tr.getTokenType().toLowerCase()); + Assert.assertNotNull(tr.getAccessToken()); + Assert.assertNotNull(tr.getRefreshToken()); + } + + public static void checkAccessToken(AccessToken at, String preferredUsername) { + logger.debug("Access token:\n{}", ModelUtils.toJSONString(at, true)); + Assert.assertNotNull(at.getPreferredUsername()); + if (preferredUsername != null) { + Assert.assertEquals(preferredUsername, at.getPreferredUsername()); + } + Assert.assertNotNull(at.getAudience()); + } + + public static void checkRefreshToken(RefreshToken rt) { + logger.debug("Refresh token:\n{}", ModelUtils.toJSONString(rt, true)); + Assert.assertNotNull(rt.getOtherClaims()); + Assert.assertNotNull(rt.getAudience()); + } +} diff --git a/src/test/resources/log4j.xml b/src/test/resources/log4j.xml new file mode 100644 index 0000000..266b477 --- /dev/null +++ b/src/test/resources/log4j.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/oidc-token-response.json b/src/test/resources/oidc-token-response.json new file mode 100644 index 0000000..9cb40c5 --- /dev/null +++ b/src/test/resources/oidc-token-response.json @@ -0,0 +1,10 @@ +{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJSSklZNEpoNF9qdDdvNmREY0NlUDFfS1l0akcxVExXVW9oMkQ2Tzk1bFNBIn0.eyJleHAiOjE2MjIyMTYxNjEsImlhdCI6MTYyMjIxNTg2MSwianRpIjoiZDM5MWQ4MTQtYTFmNi00YTQwLTlhYTMtM2Y1MWVjNTk5ODhjIiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5kZXYuZDRzY2llbmNlLm9yZy9hdXRoL3JlYWxtcy9kNHNjaWVuY2UiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiMWM4NDEwOGEtMjAxZC00ZTIwLThhZDItZDcyYjA4ZDU4ZjhhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibHI2Ml9wb3J0YWwiLCJzZXNzaW9uX3N0YXRlIjoiMWEwNTRiYjctNGQ4Ny00NGE5LWFkMWYtMTc0NmVmMmRkNTIyIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwczovL2Rldi5kNHNjaWVuY2Uub3JnIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsIkluZnJhc3RydWN0dXJlLUNsaWVudCIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJHaW5vIFN0aWxsYSIsImdyb3VwcyI6W10sInByZWZlcnJlZF91c2VybmFtZSI6Imdpbm8uc3RpbGxhIiwiZ2l2ZW5fbmFtZSI6Ikdpbm8iLCJsb2NhbGUiOiJpdCIsImZhbWlseV9uYW1lIjoiU3RpbGxhIiwiZW1haWwiOiJnaW5vQHN0aWxsYS5jb20ifQ.C43CAMgoHFhRNPACXPKDr_b1ytZeYeB2_AxTOl0jhG5YUpzoigtjwdrYptJbDtlO0fO3Ex9-KgKKBpUROMb0tC7YjuVgK6uGmaBcXGvA2S9mMLVlpl8u0KWJrrvzjPSSBHqH1fKZ6RHhZYkukMAeEeN5nT5SJoftiBNfnQi0wdjsN6fWUDLVQ3kYFQ_8C2RuO-yivSc9TyVpV-1M6ij7PEplWf2UjoygJKchs9R6x_sLHbaQHPTE24PMEY7GcEsgUwBXR3bkcWZ9cVuxAnbcIYITT-qC6V4YXodS2cYew3WoSaMl8LfTmIl7oFiv3lIDYvQ3dd-X8h1QkkbTPlVLOQ", + "expires_in": 300, + "refresh_expires_in": 1800, + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJjOTk5YmVjNC1iNDc4LTQ4Y2YtYmI5OS0wMWMxODY5NzcwNGIifQ.eyJleHAiOjE2MjIyMTc2NjEsImlhdCI6MTYyMjIxNTg2MSwianRpIjoiNzA2YzEyZjUtZDk3OS00MjVmLWI1NzctMzgxNWU4ZTdlNWM2IiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5kZXYuZDRzY2llbmNlLm9yZy9hdXRoL3JlYWxtcy9kNHNjaWVuY2UiLCJhdWQiOiJodHRwczovL2FjY291bnRzLmRldi5kNHNjaWVuY2Uub3JnL2F1dGgvcmVhbG1zL2Q0c2NpZW5jZSIsInN1YiI6IjFjODQxMDhhLTIwMWQtNGUyMC04YWQyLWQ3MmIwOGQ1OGY4YSIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJscjYyX3BvcnRhbCIsInNlc3Npb25fc3RhdGUiOiIxYTA1NGJiNy00ZDg3LTQ0YTktYWQxZi0xNzQ2ZWYyZGQ1MjIiLCJzY29wZSI6ImVtYWlsIHByb2ZpbGUifQ.2nYaWSEIbzr56vKx39AxomfiWoSQweAnepf7p3maZMs", + "token_type": "bearer", + "not-before-policy": 1618317421, + "session_state": "1a054bb7-4d87-44a9-ad1f-1746ef2dd522", + "scope": "email profile" +} \ No newline at end of file diff --git a/src/test/resources/uma-access-token.json b/src/test/resources/uma-access-token.json new file mode 100644 index 0000000..11c9ea3 --- /dev/null +++ b/src/test/resources/uma-access-token.json @@ -0,0 +1,63 @@ +{ + "exp": 1621960710, + "iat": 1621960410, + "jti": "5a2a2240-8a32-40c9-8cc2-456dd8b089d9", + "iss": "https://accounts.dev.d4science.org/auth/realms/d4science", + "aud": "conductor-server", + "sub": "a47dfe16-b4ed-44ed-a1d9-97ecd504360c", + "typ": "Bearer", + "azp": "keycloak-client", + "session_state": "1550e4ef-5a92-430d-aa0f-242e5f8048de", + "acr": "1", + "realm_access": { + "roles": [ + "offline_access", + "Infrastructure-Client", + "uma_authorization" + ] + }, + "resource_access": { + "keycloak-client": { + "roles": [ + "uma_protection" + ] + }, + "account": { + "roles": [ + "manage-account", + "manage-account-links", + "view-profile" + ] + } + }, + "authorization": { + "permissions": [ + { + "scopes": [ + "get" + ], + "rsid": "249fd469-79c5-4b85-b195-f29b3eb60345", + "rsname": "metadata" + }, + { + "scopes": [ + "get", + "start", + "terminate" + ], + "rsid": "a6f3eade-7404-4e5d-9070-800adb5aac4e", + "rsname": "workflow" + }, + { + "rsid": "1b6c00b7-9139-4eaa-aac7-20231fee05a5", + "rsname": "Default Resource" + } + ] + }, + "scope": "email profile", + "clientId": "keycloak-client", + "clientHost": "2.231.31.240", + "email_verified": false, + "preferred_username": "service-account-keycloak-client", + "clientAddress": "2.231.31.240" +} \ No newline at end of file diff --git a/src/test/resources/uma-refresh-token.json b/src/test/resources/uma-refresh-token.json new file mode 100644 index 0000000..0d61fcf --- /dev/null +++ b/src/test/resources/uma-refresh-token.json @@ -0,0 +1,36 @@ +{ + "exp": 1621962210, + "iat": 1621960410, + "jti": "ca223961-22a2-4171-af3e-f109749e83ea", + "iss": "https://accounts.dev.d4science.org/auth/realms/d4science", + "aud": "https://accounts.dev.d4science.org/auth/realms/d4science", + "sub": "a47dfe16-b4ed-44ed-a1d9-97ecd504360c", + "typ": "Refresh", + "azp": "keycloak-client", + "session_state": "1550e4ef-5a92-430d-aa0f-242e5f8048de", + "authorization": { + "permissions": [ + { + "scopes": [ + "get" + ], + "rsid": "249fd469-79c5-4b85-b195-f29b3eb60345", + "rsname": "metadata" + }, + { + "scopes": [ + "get", + "start", + "terminate" + ], + "rsid": "a6f3eade-7404-4e5d-9070-800adb5aac4e", + "rsname": "workflow" + }, + { + "rsid": "1b6c00b7-9139-4eaa-aac7-20231fee05a5", + "rsname": "Default Resource" + } + ] + }, + "scope": "email profile" +} \ No newline at end of file diff --git a/src/test/resources/uma-token-response.json b/src/test/resources/uma-token-response.json new file mode 100644 index 0000000..02c0e39 --- /dev/null +++ b/src/test/resources/uma-token-response.json @@ -0,0 +1,9 @@ +{ + "upgraded": false, + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJSSklZNEpoNF9qdDdvNmREY0NlUDFfS1l0akcxVExXVW9oMkQ2Tzk1bFNBIn0.eyJleHAiOjE2MjE5NjA3MTAsImlhdCI6MTYyMTk2MDQxMCwianRpIjoiNWEyYTIyNDAtOGEzMi00MGM5LThjYzItNDU2ZGQ4YjA4OWQ5IiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5kZXYuZDRzY2llbmNlLm9yZy9hdXRoL3JlYWxtcy9kNHNjaWVuY2UiLCJhdWQiOiJjb25kdWN0b3Itc2VydmVyIiwic3ViIjoiYTQ3ZGZlMTYtYjRlZC00NGVkLWExZDktOTdlY2Q1MDQzNjBjIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoia2V5Y2xvYWstY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6IjE1NTBlNGVmLTVhOTItNDMwZC1hYTBmLTI0MmU1ZjgwNDhkZSIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJJbmZyYXN0cnVjdHVyZS1DbGllbnQiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImtleWNsb2FrLWNsaWVudCI6eyJyb2xlcyI6WyJ1bWFfcHJvdGVjdGlvbiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwiYXV0aG9yaXphdGlvbiI6eyJwZXJtaXNzaW9ucyI6W3sic2NvcGVzIjpbImdldCJdLCJyc2lkIjoiMjQ5ZmQ0NjktNzljNS00Yjg1LWIxOTUtZjI5YjNlYjYwMzQ1IiwicnNuYW1lIjoibWV0YWRhdGEifSx7InNjb3BlcyI6WyJnZXQiLCJzdGFydCIsInRlcm1pbmF0ZSJdLCJyc2lkIjoiYTZmM2VhZGUtNzQwNC00ZTVkLTkwNzAtODAwYWRiNWFhYzRlIiwicnNuYW1lIjoid29ya2Zsb3cifSx7InJzaWQiOiIxYjZjMDBiNy05MTM5LTRlYWEtYWFjNy0yMDIzMWZlZTA1YTUiLCJyc25hbWUiOiJEZWZhdWx0IFJlc291cmNlIn1dfSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiY2xpZW50SWQiOiJrZXljbG9hay1jbGllbnQiLCJjbGllbnRIb3N0IjoiMi4yMzEuMzEuMjQwIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZXJ2aWNlLWFjY291bnQta2V5Y2xvYWstY2xpZW50IiwiY2xpZW50QWRkcmVzcyI6IjIuMjMxLjMxLjI0MCJ9.UKcREwcaJc9tpfUIsIfqbN-uON1lrtAcVQSoZan29hyQ-t8o6tjWS4-ix8JnWN8YBxU0Gbo1XcGx2NEnX7QCcAt9R46I9jpd5D9LBF-DF1G5zTVc1Cwm9-XcQ9vU_KDJ_qOzhcbPe1ZeAkYV4LpRXuPS7bBSUiNYExHoWBQTUTjNUc7rJRGWk14YKNjEgvri46RZw3ZZQ19JdjktyLz4WNGF8asSAmLXTeJ4q7O1kWttDzxjiz6QMW1378lYCb_GfXWsnAWbm7zpfz2-Fs3NmZO35BUw_jba_l_8Uog35X9qhsgcw2-_sWEB0vGLEHvz2zowpy70zjpoeHZYq6LeBw", + "expires_in": 300, + "refresh_expires_in": 1800, + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJjOTk5YmVjNC1iNDc4LTQ4Y2YtYmI5OS0wMWMxODY5NzcwNGIifQ.eyJleHAiOjE2MjE5NjIyMTAsImlhdCI6MTYyMTk2MDQxMCwianRpIjoiY2EyMjM5NjEtMjJhMi00MTcxLWFmM2UtZjEwOTc0OWU4M2VhIiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5kZXYuZDRzY2llbmNlLm9yZy9hdXRoL3JlYWxtcy9kNHNjaWVuY2UiLCJhdWQiOiJodHRwczovL2FjY291bnRzLmRldi5kNHNjaWVuY2Uub3JnL2F1dGgvcmVhbG1zL2Q0c2NpZW5jZSIsInN1YiI6ImE0N2RmZTE2LWI0ZWQtNDRlZC1hMWQ5LTk3ZWNkNTA0MzYwYyIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJrZXljbG9hay1jbGllbnQiLCJzZXNzaW9uX3N0YXRlIjoiMTU1MGU0ZWYtNWE5Mi00MzBkLWFhMGYtMjQyZTVmODA0OGRlIiwiYXV0aG9yaXphdGlvbiI6eyJwZXJtaXNzaW9ucyI6W3sic2NvcGVzIjpbImdldCJdLCJyc2lkIjoiMjQ5ZmQ0NjktNzljNS00Yjg1LWIxOTUtZjI5YjNlYjYwMzQ1IiwicnNuYW1lIjoibWV0YWRhdGEifSx7InNjb3BlcyI6WyJnZXQiLCJzdGFydCIsInRlcm1pbmF0ZSJdLCJyc2lkIjoiYTZmM2VhZGUtNzQwNC00ZTVkLTkwNzAtODAwYWRiNWFhYzRlIiwicnNuYW1lIjoid29ya2Zsb3cifSx7InJzaWQiOiIxYjZjMDBiNy05MTM5LTRlYWEtYWFjNy0yMDIzMWZlZTA1YTUiLCJyc25hbWUiOiJEZWZhdWx0IFJlc291cmNlIn1dfSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIn0.63dE64hNYpxQRV-M5zOrLLWt9cehJI4DcIbHia977r4", + "token_type": "Bearer", + "not-before-policy": 1618317421 +} \ No newline at end of file