diff --git a/.github/workflows/publish-image-keycloak.yml b/.github/workflows/publish-image-keycloak.yml index e7ca9955..051277ac 100644 --- a/.github/workflows/publish-image-keycloak.yml +++ b/.github/workflows/publish-image-keycloak.yml @@ -64,7 +64,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - file: docker/keycloak/Dockerfile-${{ startsWith(github.ref, 'refs/tags/7.4-37') && '7.4-37' || '7.6' }} + file: docker/keycloak/Dockerfile-${{ startsWith(github.ref, 'refs/tags/7.6') && '7.6' || '24' }} cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache-new diff --git a/.github/workflows/publish-image-rhbk-dev.yml b/.github/workflows/publish-image-rhbk-dev.yml new file mode 100644 index 00000000..90fb4fba --- /dev/null +++ b/.github/workflows/publish-image-rhbk-dev.yml @@ -0,0 +1,65 @@ +name: Create and publish Keycloak Docker image - Dev + +on: + push: + branches: + - 'feature/quarkus' + +env: + GITHUB_REGISTRY: ghcr.io + REDHAT_REGISTRY: registry.redhat.io + IMAGE_NAME: bcgov/sso + +jobs: + build-and-push-image: + runs-on: ubuntu-20.04 + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the GitHub Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to the REDHAT Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REDHAT_REGISTRY }} + username: ${{ secrets.REDHAT_USERNAME }} + password: ${{ secrets.REDHAT_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: docker/keycloak + push: true + tags: ${{ env.GITHUB_REGISTRY }}/${{env.IMAGE_NAME}}:dev-rhbk-24 + file: docker/keycloak/Dockerfile-24 + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/docker/kc-cron-job/yarn.lock b/docker/kc-cron-job/yarn.lock index d50446de..3a525007 100644 --- a/docker/kc-cron-job/yarn.lock +++ b/docker/kc-cron-job/yarn.lock @@ -1146,9 +1146,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== camelize@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b" - integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" + integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== caniuse-lite@^1.0.30001541: version "1.0.30001550" @@ -1315,9 +1315,9 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.2: ms "2.1.2" decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + version "0.2.2" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" + integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== dedent@^1.0.0: version "1.5.1" @@ -1830,7 +1830,7 @@ fill-range@^7.0.1: filter-obj@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" - integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= + integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" @@ -1863,9 +1863,9 @@ flatted@^3.2.9: integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== follow-redirects@^1.14.0: - version "1.14.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" - integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== follow-redirects@^1.15.0: version "1.15.2" @@ -3739,7 +3739,7 @@ stack-utils@^2.0.3: strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" - integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= + integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== string-length@^4.0.1: version "4.0.2" @@ -4031,7 +4031,7 @@ url-join@^4.0.0: url-template@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" - integrity sha1-/FZaPMy/93MMd19WQflVV5FDnyE= + integrity sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw== util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" diff --git a/docker/keycloak/Dockerfile-24 b/docker/keycloak/Dockerfile-24 new file mode 100644 index 00000000..567230d0 --- /dev/null +++ b/docker/keycloak/Dockerfile-24 @@ -0,0 +1,36 @@ +FROM maven:3.8.5-openjdk-17-slim AS extensions-builder + +COPY ./extensions-24 /tmp/ +WORKDIR /tmp/ +RUN mvn -B clean package --file pom.xml + +FROM registry.redhat.io/rhbk/keycloak-rhel9:24-10 as builder + +# Enable health and metrics support +ENV KC_HEALTH_ENABLED=true +ENV KC_METRICS_ENABLED=true + +# Configure a database vendor +ENV KC_DB=postgres + +COPY --from=extensions-builder /tmp/services/target/bcgov-services-1.0.0.jar /opt/keycloak/providers/ + +WORKDIR /opt/keycloak + +RUN /opt/keycloak/bin/kc.sh build + +FROM registry.redhat.io/rhbk/keycloak-rhel9:24-10 + +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + +# copy the theme directory to `/opt/keycloak/themes/` for now, but we can consider to archive to be deployed later. +COPY ./extensions-24/themes/src/main/resources/theme /opt/keycloak/themes + +COPY ./configuration/24/keycloak.conf /opt/keycloak/conf + +COPY ./configuration/24/quarkus.properties /opt/keycloak/conf + +COPY ./configuration/24/keycloak-default-user-profile.json /tmp + +# change these values to point to a running postgres instance +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] diff --git a/docker/keycloak/configuration/24/keycloak-default-user-profile.json b/docker/keycloak/configuration/24/keycloak-default-user-profile.json new file mode 100644 index 00000000..231984e3 --- /dev/null +++ b/docker/keycloak/configuration/24/keycloak-default-user-profile.json @@ -0,0 +1,61 @@ +{ + "unmanagedAttributePolicy": "ENABLED", + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "validations": { + "length": { "min": 3, "max": 255 }, + "username-prohibited-characters": {}, + "up-username-not-idn-homograph": {} + } + }, + { + "name": "email", + "displayName": "${email}", + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "validations": { + "email": {}, + "length": { "max": 255 } + } + }, + { + "name": "firstName", + "displayName": "${firstName}", + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "validations": { + "length": { "max": 255 }, + "person-name-prohibited-characters": {} + } + }, + { + "name": "lastName", + "displayName": "${lastName}", + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "validations": { + "length": { "max": 255 }, + "person-name-prohibited-characters": {} + } + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata" + } + ] +} diff --git a/docker/keycloak/configuration/24/keycloak.conf b/docker/keycloak/configuration/24/keycloak.conf new file mode 100644 index 00000000..17325e88 --- /dev/null +++ b/docker/keycloak/configuration/24/keycloak.conf @@ -0,0 +1,39 @@ +health-enabled=true +metrics-enabled=true + +# database +db-pool-min-size=5 +db-pool-max-size=20 + +# theme +spi-theme-static-max-age=2592000 +spi-theme-cache-themes=true +spi-theme-cache-templates=true + +# logging +log=console,file +log-console-color=false +log-file=/var/log/eap/${HOSTNAME}.log + +# root-logger-level:INFO +log-level=info,com.arjuna:warn,io.jaegertracing.Configuration:warn,org.jboss.as.config:debug,org.keycloak.events:debug,sun.rmi:warn +log-console-output=json +log-file-output=json + +# SPIs +spi-login-protocol-openid-connect-legacy-logout-redirect-uri=true +spi-user-profile-declarative-user-profile-config-file=/tmp/keycloak-default-user-profile.json + +# cache +cache=ispn +# DNS_PING is particularly useful in environments like Kubernetes and Red Hat OpenShift where UDP multicast, a different cluster discovery method, might not be available. This is because DNS is a standard service that's always available, making DNS_PING a reliable way for Infinispan nodes to discover each other. +# The below option requires passing -Djgroups.dns.query=sso-keycloak-ping..svc.cluster.local to start command +cache-stack=kubernetes +#cache-config-file=cache-ispn-custom.xml + +# tls +# https-key-store-file=server.keystore +# https-key-store-password=password + +http-enabled=true +proxy-headers=forwarded diff --git a/docker/keycloak/configuration/24/quarkus.properties b/docker/keycloak/configuration/24/quarkus.properties new file mode 100644 index 00000000..f27a0b14 --- /dev/null +++ b/docker/keycloak/configuration/24/quarkus.properties @@ -0,0 +1,10 @@ +quarkus.log.console.json.exception-output-type=formatted +quarkus.log.console.json.key-overrides=timestamp=@timestamp +quarkus.log.console.json.additional-field."@version".value=1 +quarkus.log.file.json.exception-output-type=formatted +quarkus.log.file.json.key-overrides=timestamp=@timestamp +quarkus.log.file.json.additional-field."@version".value=1 +quarkus.log.file.rotation.file-suffix=.yyyy-MM-dd +# Optional: Disable rotation by size (adjust value as needed) +quarkus.log.handler.file.rotation.max-file-size="10000M" +quarkus.log.handler.file.rotation.max-backup-index="100" diff --git a/docker/keycloak/extensions-24/.vscode/settings.json b/docker/keycloak/extensions-24/.vscode/settings.json new file mode 100644 index 00000000..385f27a2 --- /dev/null +++ b/docker/keycloak/extensions-24/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} diff --git a/docker/keycloak/extensions-24/pom.xml b/docker/keycloak/extensions-24/pom.xml new file mode 100644 index 00000000..524c849a --- /dev/null +++ b/docker/keycloak/extensions-24/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + com.github.bcgov.keycloak + extensions-parent + 1.0.0 + pom + + + 17 + 17 + UTF-8 + 24.0.5 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + + + + services + themes + + diff --git a/docker/keycloak/extensions-24/services/pom.xml b/docker/keycloak/extensions-24/services/pom.xml new file mode 100644 index 00000000..e544a4e4 --- /dev/null +++ b/docker/keycloak/extensions-24/services/pom.xml @@ -0,0 +1,153 @@ + + + 4.0.0 + + + com.github.bcgov.keycloak + extensions-parent + 1.0.0 + + + bcgov-services + jar + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + + + classworlds:classworlds + junit:junit + jmock:* + *:xml-apis + org.apache.maven:lib:tests + + + false + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + + + + + + org.keycloak.bom + keycloak-spi-bom + ${keycloak.version} + pom + import + + + + com.fasterxml.jackson.core + jackson-core + 2.14.0 + provided + + + com.fasterxml.jackson.core + jackson-databind + 2.14.0 + provided + + + com.fasterxml.jackson.core + jackson-annotations + 2.14.0 + provided + + + + + junit + junit + 4.13.2 + test + + + org.hamcrest + hamcrest-all + 1.3 + test + + + + + + + org.keycloak + keycloak-core + ${keycloak.version} + provided + + + org.keycloak + keycloak-saml-core-public + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.keycloak + keycloak-model-infinispan + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + + + junit + junit + test + + + org.mockito + mockito-all + 1.9.5 + test + + + org.hamcrest + hamcrest-all + test + + + diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/BrokeredIdentityContext.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/BrokeredIdentityContext.java new file mode 100644 index 00000000..42b95e5c --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/BrokeredIdentityContext.java @@ -0,0 +1,14 @@ +package com.github.bcgov.keycloak.authenticators; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class BrokeredIdentityContext { + @JsonProperty("identityProviderId") + protected String identityProviderId; + + public String getIdentityProviderId() { + return identityProviderId; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginAuthenticator.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginAuthenticator.java new file mode 100644 index 00000000..fceb08f3 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginAuthenticator.java @@ -0,0 +1,67 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.*; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.util.Objects; +import java.util.Optional; + +/** @author Junmin Ahn */ +public class ClientLoginAuthenticator implements Authenticator { + + private static final String DEFAULT_CLIENT_MEMBER_ROLE = "member"; + + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticatorConfigModel config = context.getAuthenticatorConfig(); + + String mrole = DEFAULT_CLIENT_MEMBER_ROLE; + if (config != null + && config.getConfig() != null + && config.getConfig().containsKey(ClientLoginAuthenticatorFactory.MEMBER_ROLE_NAME)) { + mrole = config.getConfig().get(ClientLoginAuthenticatorFactory.MEMBER_ROLE_NAME); + } + + final String clientMemberRole = mrole; + + AuthenticationSessionModel session = context.getAuthenticationSession(); + ClientModel client = session.getClient(); + UserModel user = session.getAuthenticatedUser(); + RoleModel memberRole = client.getRole(clientMemberRole); + if (memberRole == null) { + memberRole = client.addRole(clientMemberRole); + } + + Optional assignedMemberRole = + user.getClientRoleMappingsStream(client) + .filter(role -> Objects.equals(clientMemberRole, role.getName())) + .findFirst(); + + if (!assignedMemberRole.isPresent()) { + user.grantRole(memberRole); + } + + context.success(); + } + + @Override + public void action(AuthenticationFlowContext context) { /* This is ok */ } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginAuthenticatorFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginAuthenticatorFactory.java new file mode 100644 index 00000000..8c7ba734 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginAuthenticatorFactory.java @@ -0,0 +1,87 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.List; + +/** @author Junmin Ahn */ +public class ClientLoginAuthenticatorFactory extends UsernamePasswordFormFactory { + + protected static final Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.ALTERNATIVE, Requirement.DISABLED + }; + + public static final String MEMBER_ROLE_NAME = "memberRole"; + private static final List configProperties = new ArrayList<>(); + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(MEMBER_ROLE_NAME); + property.setLabel("Client Member Role"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Role name to grant to user. default to 'member'"); + configProperties.add(property); + } + + @Override + public String getId() { + return "client-login-authenticator"; + } + + @Override + public String getDisplayType() { + return "Client Login Authenticator"; + } + + @Override + public String getHelpText() { + return "Associates the authenticating/authenticated users to the client"; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new ClientLoginAuthenticator(); + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public void init(Config.Scope config) { /* This is ok */ } + + @Override + public void postInit(KeycloakSessionFactory factory) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginRoleBinding.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginRoleBinding.java new file mode 100644 index 00000000..05becec3 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginRoleBinding.java @@ -0,0 +1,56 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.*; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.util.Objects; +import java.util.Optional; + +/** @author Junmin Ahn */ +public class ClientLoginRoleBinding implements Authenticator { + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticationSessionModel session = context.getAuthenticationSession(); + ClientModel client = session.getClient(); + RealmModel realm = session.getRealm(); + UserModel user = session.getAuthenticatedUser(); + + String targetRoleName = "client-" + client.getClientId(); + RoleModel clientRole = realm.getRole(targetRoleName); + if (clientRole == null) { + clientRole = realm.addRole(targetRoleName); + } + + Optional assignedRole = + user.getRealmRoleMappingsStream() + .filter(role -> Objects.equals(targetRoleName, role.getName())) + .findFirst(); + + if (!assignedRole.isPresent()) { + user.grantRole(clientRole); + } + + context.success(); + } + + @Override + public void action(AuthenticationFlowContext context) { /* This is ok */ } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginRoleBindingFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginRoleBindingFactory.java new file mode 100644 index 00000000..60a7fe89 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/ClientLoginRoleBindingFactory.java @@ -0,0 +1,72 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.Collections; +import java.util.List; + +/** @author Junmin Ahn */ +public class ClientLoginRoleBindingFactory extends UsernamePasswordFormFactory { + + protected static final Requirement[] REQUIREMENT_CHOICES = {Requirement.REQUIRED}; + + @Override + public String getId() { + return "client-login-role-binding"; + } + + @Override + public String getDisplayType() { + return "Client Login Role Binding"; + } + + @Override + public String getHelpText() { + return "assign the authenticated user the realm-level role for the client"; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new ClientLoginRoleBinding(); + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public void init(Config.Scope config) { /* This is ok */ } + + @Override + public void postInit(KeycloakSessionFactory factory) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/CookieStopAuthenticator.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/CookieStopAuthenticator.java new file mode 100644 index 00000000..12e82769 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/CookieStopAuthenticator.java @@ -0,0 +1,101 @@ +package com.github.bcgov.keycloak.authenticators; + +import jakarta.ws.rs.core.MultivaluedMap; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.constants.AdapterConstants; +import org.keycloak.models.*; +import org.keycloak.protocol.LoginProtocol; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.util.Map; + +/** @author Junmin Ahn */ +public class CookieStopAuthenticator implements Authenticator { + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie( + context.getSession(), context.getRealm(), true); + + // 1. If no Cookie session, proceed to login process + if (authResult == null) { + context.attempted(); + return; + } + + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol()); + context.setUser(authResult.getUser()); + + // 2. if re-authentication is required, proceed to login process + if (protocol.requireReauthentication(authResult.getSession(), authSession)) { + context.attempted(); + return; + } + + MultivaluedMap queryParams = context.getUriInfo().getQueryParameters(); + + // 3. If a target IDP is passed via "kc_idp_hint" query param, and + // i. the target IDP is enabled; + // ii. the target IDP is allowed for the authenticating client; + // iii. the target IDP is different one than the one in the user session; + // then, logout the user from the current session and proceed to login process + if (queryParams.containsKey(AdapterConstants.KC_IDP_HINT)) { + String authIdp = queryParams.getFirst(AdapterConstants.KC_IDP_HINT); + String sessIdp = authResult.getSession().getNotes().get("identity_provider"); + + if (authIdp != null && !authIdp.trim().isEmpty()) { + IdentityProviderModel idp = context.getRealm().getIdentityProviderByAlias(authIdp); + Map scopes = context.getAuthenticationSession().getClient().getClientScopes(true); + + if (idp != null + && idp.isEnabled() + && (scopes.containsKey(authIdp) || scopes.containsKey(authIdp + "-saml")) + && authIdp != sessIdp) { + UserSessionProvider userSessionProvider = context.getSession().sessions(); + userSessionProvider.removeUserSession(context.getRealm(), authResult.getSession()); + context.attempted(); + return; + } + } + } + + String clientUUID = authSession.getClient().getId(); + AuthenticatedClientSessionModel clientSessionModel = authResult.getSession() + .getAuthenticatedClientSessionByClient(clientUUID); + + // 4. If no Cookie session with the authenticating client, proceed to login + // process + if (clientSessionModel == null) { + context.attempted(); + return; + } + + // 5. Otherwise, attach the exisiting session to the user + context.getAuthenticationSession().setAuthNote(AuthenticationManager.SSO_AUTH, "true"); + context.setUser(authResult.getUser()); + context.attachUserSession(authResult.getSession()); + context.success(); + } + + @Override + public void action(AuthenticationFlowContext context) { /* This is ok */ } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/CookieStopAuthenticatorFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/CookieStopAuthenticatorFactory.java new file mode 100644 index 00000000..37a53814 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/CookieStopAuthenticatorFactory.java @@ -0,0 +1,72 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +/** @author Junmin Ahn */ +public class CookieStopAuthenticatorFactory extends UsernamePasswordFormFactory { + protected static final Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.ALTERNATIVE, Requirement.DISABLED + }; + + @Override + public String getId() { + return "cookie-stopper"; + } + + @Override + public String getDisplayType() { + return "Cookie Stopper"; + } + + @Override + public String getHelpText() { + return "Validates the SSO cookie set by the auth server."; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new CookieStopAuthenticator(); + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public String getReferenceCategory() { + return "cookie"; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public void init(Config.Scope config) { /* This is ok */ } + + @Override + public void postInit(KeycloakSessionFactory factory) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/IdentityProviderStopAuthenticator.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/IdentityProviderStopAuthenticator.java new file mode 100644 index 00000000..ea7ba701 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/IdentityProviderStopAuthenticator.java @@ -0,0 +1,130 @@ +package com.github.bcgov.keycloak.authenticators; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationProcessor; +import org.keycloak.authentication.Authenticator; +import org.keycloak.constants.AdapterConstants; +import org.keycloak.models.*; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.ClientSessionCode; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** @author Junmin Ahn */ +public class IdentityProviderStopAuthenticator implements Authenticator { + + private static final Logger logger = Logger.getLogger(IdentityProviderStopAuthenticator.class); + + protected static final String ACCEPTS_PROMPT_NONE = "acceptsPromptNoneForwardFromClient"; + + @Override + public void authenticate(AuthenticationFlowContext context) { + List allowedIdps = new ArrayList<>(); + List realmIdps = context.getRealm().getIdentityProvidersStream().toList(); + Map scopes = + context.getAuthenticationSession().getClient().getClientScopes(true); + + for (IdentityProviderModel ridp : realmIdps) { + String oidcAlias = ridp.getAlias(); + String samlAlias = ridp.getAlias() + "-saml"; + + if (ridp.isEnabled() && (scopes.containsKey(oidcAlias) || scopes.containsKey(samlAlias))) { + allowedIdps.add(ridp); + } + } + + if (allowedIdps.size() == 1) { + IdentityProviderModel firstIdp = allowedIdps.get(0); + logger.tracef("Single IDP found, Redirecting to %s", firstIdp.getAlias()); + redirect(context, firstIdp); + } else if (allowedIdps.size() > 1) { + if (context.getUriInfo().getQueryParameters().containsKey(AdapterConstants.KC_IDP_HINT)) { + String hintIdp = + context.getUriInfo().getQueryParameters().getFirst(AdapterConstants.KC_IDP_HINT); + + if (hintIdp != null && !hintIdp.equals("")) { + for (IdentityProviderModel aidp : allowedIdps) { + if (hintIdp.equals(aidp.getAlias())) { + logger.tracef("Hint IDP found, Redirecting to %s", hintIdp); + redirect(context, aidp); + return; + } + } + } + } + + logger.tracef("Multiple IDP found, Navigating to login page"); + context.attempted(); + } else { + logger.tracef("Zero IDP found, Navigating to login page"); + context.attempted(); + } + } + + private void redirect(AuthenticationFlowContext context, IdentityProviderModel idp) { + String accessCode = + new ClientSessionCode<>( + context.getSession(), context.getRealm(), context.getAuthenticationSession()) + .getOrGenerateCode(); + String clientId = context.getAuthenticationSession().getClient().getClientId(); + String tabId = context.getAuthenticationSession().getTabId(); + URI location = + Urls.identityProviderAuthnRequest( + context.getUriInfo().getBaseUri(), + idp.getAlias(), + context.getRealm().getName(), + accessCode, + clientId, + tabId); + if (context.getAuthenticationSession().getClientNote(OAuth2Constants.DISPLAY) != null) { + location = + UriBuilder.fromUri(location) + .queryParam( + OAuth2Constants.DISPLAY, + context.getAuthenticationSession().getClientNote(OAuth2Constants.DISPLAY)) + .build(); + } + + Response response = Response.seeOther(location).build(); + + // will forward the request to the IDP with prompt=none if the IDP accepts forwards with + // prompt=none. + if ("none" + .equals( + context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.PROMPT_PARAM)) + && Boolean.valueOf(idp.getConfig().get(ACCEPTS_PROMPT_NONE))) { + context + .getAuthenticationSession() + .setAuthNote(AuthenticationProcessor.FORWARDED_PASSIVE_LOGIN, "true"); + } + + context.forceChallenge(response); + } + + @Override + public void action(AuthenticationFlowContext context) { /* This is ok */ } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/IdentityProviderStopAuthenticatorFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/IdentityProviderStopAuthenticatorFactory.java new file mode 100644 index 00000000..97e2520e --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/IdentityProviderStopAuthenticatorFactory.java @@ -0,0 +1,73 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +/** @author Junmin Ahn */ +public class IdentityProviderStopAuthenticatorFactory extends UsernamePasswordFormFactory { + + protected static final Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.ALTERNATIVE, Requirement.DISABLED + }; + + @Override + public String getId() { + return "identity-provider-stopper"; + } + + @Override + public String getDisplayType() { + return "Identity Provider Stopper"; + } + + @Override + public String getHelpText() { + return "Redirects to allowed Identity Provider or Identity Provider specified with kc_idp_hint query parameter"; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new IdentityProviderStopAuthenticator(); + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public void init(Config.Scope config) { /* This is ok */ } + + @Override + public void postInit(KeycloakSessionFactory factory) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserAttributeAuthenticator.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserAttributeAuthenticator.java new file mode 100644 index 00000000..c8129de2 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserAttributeAuthenticator.java @@ -0,0 +1,102 @@ +package com.github.bcgov.keycloak.authenticators; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.*; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.util.JsonSerialization; + +import java.net.URI; +import java.util.Map; + +/** @author Junmin Ahn */ +public class UserAttributeAuthenticator implements Authenticator { + + private static final Logger logger = Logger.getLogger(UserAttributeAuthenticator.class); + + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticationSessionModel session = context.getAuthenticationSession(); + AuthenticatorConfigModel authConfig = context.getAuthenticatorConfig(); + if (authConfig == null) { + context.failure(AuthenticationFlowError.ACCESS_DENIED, redirectResponse(session, null)); + return; + } + + Map config = authConfig.getConfig(); + if (config == null) { + context.failure(AuthenticationFlowError.ACCESS_DENIED, redirectResponse(session, null)); + return; + } + + String attributeKey = config.get(UserAttributeAuthenticatorFactory.ATTRIBUTE_KEY); + String attributeValue = config.get(UserAttributeAuthenticatorFactory.ATTRIBUTE_VALUE); + String errorUrl = config.get(UserAttributeAuthenticatorFactory.ERROR_URL); + + UserModel user = session.getAuthenticatedUser(); + RealmModel realm = session.getRealm(); + + if (!user.getAttributes().get(attributeKey).contains(attributeValue)) { + context.failure(AuthenticationFlowError.ACCESS_DENIED, redirectResponse(session, errorUrl)); + context.getSession().users().removeUser(realm, user); + return; + } + + context.success(); + } + + private Response redirectResponse(AuthenticationSessionModel session, String redirectUri) { + ClientModel client = session.getClient(); + String clientBaseUrl = client.getBaseUrl(); + String clientId = client.getClientId(); + String idp = null; + + try { + String authNote = session.getAuthNote("PBL_BROKERED_IDENTITY_CONTEXT"); + BrokeredIdentityContext brokeredIdentityContext = + JsonSerialization.readValue(authNote.getBytes(), BrokeredIdentityContext.class); + + idp = brokeredIdentityContext.getIdentityProviderId(); + } catch (Exception e) { + logger.warn("error parsing auth note: ", e); + } + + String url = ""; + if (isValidString(redirectUri)) url = redirectUri; + else if (isValidString(clientBaseUrl)) url = clientBaseUrl; + else return null; + + url = url.replace("${idp_alias}", isValidString(idp) ? idp : ""); + url = url.replace("${client_id}", isValidString(clientId) ? clientId : ""); + + URI redirect = UriBuilder.fromUri(url).build(); + return Response.status(302).location(redirect).build(); + } + + private boolean isValidString(String string) { + return string != null && !string.trim().isEmpty(); + } + + @Override + public void action(AuthenticationFlowContext context) { /* This is ok */ } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserAttributeAuthenticatorFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserAttributeAuthenticatorFactory.java new file mode 100644 index 00000000..df26b00e --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserAttributeAuthenticatorFactory.java @@ -0,0 +1,102 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.List; + +/** @author Junmin Ahn */ +public class UserAttributeAuthenticatorFactory extends UsernamePasswordFormFactory { + + protected static final Requirement[] REQUIREMENT_CHOICES = {Requirement.REQUIRED}; + + public static final String ATTRIBUTE_KEY = "attributeKey"; + public static final String ATTRIBUTE_VALUE = "attributeValue"; + public static final String ERROR_URL = "errorUrl"; + private static final List configProperties = new ArrayList<>(); + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(ATTRIBUTE_KEY); + property.setLabel("Attribute Key"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Attribute key to look for the value"); + configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(ATTRIBUTE_VALUE); + property.setLabel("Attribute Value"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Attribute value to match with"); + configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(ERROR_URL); + property.setLabel("Error URL"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText( + "Error URL to redirect the request when the attribute does not match. (Defaults to client base URL)"); + configProperties.add(property); + } + + @Override + public String getId() { + return "user-attribute-authenticator"; + } + + @Override + public String getDisplayType() { + return "User Attribute Authenticator"; + } + + @Override + public String getHelpText() { + return "Authenticate user based on it's attribute"; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new UserAttributeAuthenticator(); + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public void init(Config.Scope config) { /* This is ok */ } + + @Override + public void postInit(KeycloakSessionFactory factory) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserSessionRemover.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserSessionRemover.java new file mode 100644 index 00000000..fc4c27dc --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserSessionRemover.java @@ -0,0 +1,72 @@ +package com.github.bcgov.keycloak.authenticators; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.util.Map; + +public class UserSessionRemover implements Authenticator { + + private static final Logger logger = Logger.getLogger(UserSessionRemover.class); + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticationSessionModel session = context.getAuthenticationSession(); + AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie( + context.getSession(), + context.getRealm(), + true + ); + + // 1. If no Cookie session, proceed to next step + if (authResult == null) { + context.attempted(); + return; + } + + // Need to use the KeycloakSession context to get the authenticating client ID. Not available on the AuthenticationFlowContext. + KeycloakSession keycloakSession = context.getSession(); + String authenticatingClientUUID = keycloakSession.getContext().getClient().getId(); + + // Get all existing sessions. If any session is associated with a different client, clear all user sessions. + UserSessionProvider userSessionProvider = keycloakSession.sessions(); + Map activeClientSessionStats = userSessionProvider.getActiveClientSessionStats(context.getRealm(), false); + + for (String activeSessionClientUUID : activeClientSessionStats.keySet()) { + if (!activeSessionClientUUID.equals(authenticatingClientUUID)) { + userSessionProvider.removeUserSession(context.getRealm(), authResult.getSession()); + } + } + + context.attempted(); + } + + @Override + public void action(AuthenticationFlowContext context) { + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserSessionRemoverFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserSessionRemoverFactory.java new file mode 100644 index 00000000..8194f68c --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/UserSessionRemoverFactory.java @@ -0,0 +1,76 @@ +package com.github.bcgov.keycloak.authenticators; + +import java.util.List; +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class UserSessionRemoverFactory implements AuthenticatorFactory { + + protected static final Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.ALTERNATIVE, Requirement.DISABLED + }; + + private static final Authenticator AUTHENTICATOR_INSTANCE = new UserSessionRemover(); + + @Override + public String getId() { + return "user-session-remover"; + } + + @Override + public String getDisplayType() { + return "User Session Remover"; + } + + @Override + public String getHelpText() { + return "Checks if the user session is realted to any other client, and removes it if so."; + } + + @Override + public Authenticator create(KeycloakSession session) { + return AUTHENTICATOR_INSTANCE; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/broker/IdpDeleteUserIfDuplicateAuthenticator.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/broker/IdpDeleteUserIfDuplicateAuthenticator.java new file mode 100644 index 00000000..0c778d19 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/broker/IdpDeleteUserIfDuplicateAuthenticator.java @@ -0,0 +1,124 @@ +package com.github.bcgov.keycloak.authenticators.broker; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; +import org.keycloak.authentication.authenticators.broker.util.ExistingUserInfo; +import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.ServicesLogger; + +import java.util.List; +import java.util.Map; + +/** @author Junmin Ahn */ +public class IdpDeleteUserIfDuplicateAuthenticator extends AbstractIdpAuthenticator { + + private static Logger logger = Logger.getLogger(IdpDeleteUserIfDuplicateAuthenticator.class); + + @Override + protected void actionImpl( + AuthenticationFlowContext context, + SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { /* This is ok */ } + + @Override + protected void authenticateImpl( + AuthenticationFlowContext context, + SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + + if (context.getAuthenticationSession().getAuthNote(EXISTING_USER_INFO) != null) { + context.attempted(); + return; + } + + String username = getUsername(context, serializedCtx, brokerContext); + if (username == null) { + ServicesLogger.LOGGER.resetFlow(realm.isRegistrationEmailAsUsername() ? "Email" : "Username"); + context.getAuthenticationSession().setAuthNote(ENFORCE_UPDATE_PROFILE, "true"); + context.resetFlow(); + return; + } + + ExistingUserInfo duplication = + checkExistingUser(context, username, serializedCtx, brokerContext); + + if (duplication != null) { + logger.debugf( + "Duplication detected. There is already existing user with %s '%s' .", + duplication.getDuplicateAttributeName(), duplication.getDuplicateAttributeValue()); + + UserModel federatedUser = session.users().getUserById(realm, duplication.getExistingUserId()); + session.users().removeUser(realm, federatedUser); + } + + logger.debugf( + "No duplication detected. Creating account for user '%s' and linking with identity provider '%s' .", + username, brokerContext.getIdpConfig().getAlias()); + + UserModel federatedUser = session.users().addUser(realm, username); + federatedUser.setEnabled(true); + + for (Map.Entry> attr : serializedCtx.getAttributes().entrySet()) { + if (!UserModel.USERNAME.equalsIgnoreCase(attr.getKey())) { + federatedUser.setAttribute(attr.getKey(), attr.getValue()); + } + } + + context.setUser(federatedUser); + context.getAuthenticationSession().setAuthNote(BROKER_REGISTERED_NEW_USER, "true"); + context.success(); + } + + // Could be overriden to detect duplication based on other criterias (firstName, lastName, ...) + protected ExistingUserInfo checkExistingUser( + AuthenticationFlowContext context, + String username, + SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + + if (brokerContext.getEmail() != null && !context.getRealm().isDuplicateEmailsAllowed()) { + UserModel existingUser = + context.getSession().users().getUserByEmail(context.getRealm(), brokerContext.getEmail()); + if (existingUser != null) { + return new ExistingUserInfo(existingUser.getId(), UserModel.EMAIL, existingUser.getEmail()); + } + } + + UserModel existingUser = + context.getSession().users().getUserByUsername(context.getRealm(), username); + if (existingUser != null) { + return new ExistingUserInfo( + existingUser.getId(), UserModel.USERNAME, existingUser.getUsername()); + } + + return null; + } + + protected String getUsername( + AuthenticationFlowContext context, + SerializedBrokeredIdentityContext serializedCtx, + BrokeredIdentityContext brokerContext) { + RealmModel realm = context.getRealm(); + return realm.isRegistrationEmailAsUsername() + ? brokerContext.getEmail() + : brokerContext.getModelUsername(); + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/broker/IdpDeleteUserIfDuplicateAuthenticatorFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/broker/IdpDeleteUserIfDuplicateAuthenticatorFactory.java new file mode 100644 index 00000000..f7da8a22 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/broker/IdpDeleteUserIfDuplicateAuthenticatorFactory.java @@ -0,0 +1,73 @@ +package com.github.bcgov.keycloak.authenticators.broker; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +/** @author Junmin Ahn */ +public class IdpDeleteUserIfDuplicateAuthenticatorFactory implements AuthenticatorFactory { + + protected static final Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.ALTERNATIVE, Requirement.DISABLED + }; + + @Override + public String getId() { + return "idp-delete-user-if-duplicate"; + } + + @Override + public String getDisplayType() { + return "Delete User If Duplicate"; + } + + @Override + public String getHelpText() { + return "Delete old user if there is duplicate Keycloak account with the same username"; + } + + @Override + public Authenticator create(KeycloakSession session) { + return new IdpDeleteUserIfDuplicateAuthenticator(); + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public String getReferenceCategory() { + return "deleteUserIfDuplicate"; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public void init(Config.Scope config) { /* This is ok */ } + + @Override + public void postInit(KeycloakSessionFactory factory) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/browser/IdentityProviderStopForm.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/browser/IdentityProviderStopForm.java new file mode 100644 index 00000000..22f38178 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/browser/IdentityProviderStopForm.java @@ -0,0 +1,98 @@ +package com.github.bcgov.keycloak.authenticators.browser; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.*; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.managers.AuthenticationManager; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** @author Junmin Ahn */ +public class IdentityProviderStopForm extends AbstractUsernameFormAuthenticator { + protected static ServicesLogger log = ServicesLogger.LOGGER; + + @Override + public void action(AuthenticationFlowContext context) { + context.attempted(); + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + List realmIdps = context.getRealm().getIdentityProvidersStream().toList(); + Map scopes = context.getAuthenticationSession().getClient().getClientScopes(true); + + Map> idpContext = new HashMap<>(); + + for (IdentityProviderModel ridp : realmIdps) { + String oidcAlias = ridp.getAlias(); + String samlAlias = oidcAlias + "-saml"; + + if (ridp.isEnabled() && (scopes.containsKey(oidcAlias) || scopes.containsKey(samlAlias))) { + Map data = new HashMap<>(); + data.put("enabled", "true"); + + String tooltip = ridp.getConfig().get("tooltip"); + if (tooltip != null && tooltip.length() > 0) { + data.put("tooltip", tooltip); + } + + idpContext.put(oidcAlias, data); + } + } + + MultivaluedMap formData = new MultivaluedHashMap<>(); + + ObjectMapper objectMapper = new ObjectMapper(); + try { + String json = objectMapper.writeValueAsString(idpContext); + log.tracef("idp context: %s", json); + formData.add(AuthenticationManager.FORM_USERNAME, json); + } catch (JsonProcessingException e) { + e.printStackTrace(); + formData.add(AuthenticationManager.FORM_USERNAME, "{}"); + } + + Response challengeResponse = challenge(context, formData); + context.challenge(challengeResponse); + } + + @Override + public boolean requiresUser() { + return false; + } + + protected Response challenge( + AuthenticationFlowContext context, MultivaluedMap formData) { + LoginFormsProvider forms = context.form(); + + if (formData.size() > 0) + forms.setFormData(formData); + + return forms.createLoginUsernamePassword(); + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + // never called + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + // never called + } + + @Override + public void close() { + /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/browser/IdentityProviderStopFormFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/browser/IdentityProviderStopFormFactory.java new file mode 100644 index 00000000..91fb71af --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/authenticators/browser/IdentityProviderStopFormFactory.java @@ -0,0 +1,73 @@ +package com.github.bcgov.keycloak.authenticators.browser; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +/** @author Junmin Ahn */ +public class IdentityProviderStopFormFactory extends UsernamePasswordFormFactory { + protected static final Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.ALTERNATIVE, Requirement.DISABLED + }; + public static final IdentityProviderStopForm SINGLETON = new IdentityProviderStopForm(); + + @Override + public String getId() { + return "identity-provider-stop-form"; + } + + @Override + public String getDisplayType() { + return "Identity Provider Stop Form"; + } + + @Override + public String getHelpText() { + return "Display allowed IDPs for the authenticating client"; + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public void init(Config.Scope config) { /* This is ok */ } + + @Override + public void postInit(KeycloakSessionFactory factory) { /* This is ok */ } + + @Override + public void close() { /* This is ok */ } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/CustomOIDCIdentityProvider.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/CustomOIDCIdentityProvider.java new file mode 100644 index 00000000..4dbc707f --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/CustomOIDCIdentityProvider.java @@ -0,0 +1,22 @@ +package com.github.bcgov.keycloak.broker.oidc; + +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.models.KeycloakSession; +import org.keycloak.representations.JsonWebToken; + +/** @author Junmin Ahn */ +public class CustomOIDCIdentityProvider extends OIDCIdentityProvider { + + public CustomOIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { + super(session, config); + } + + @Override + protected JsonWebToken validateToken(String encodedToken, boolean ignoreAudience) { + // logger.warn(encodedToken); + // logger.warn(ignoreAudience); + + return super.validateToken(encodedToken, ignoreAudience); + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/CustomOIDCIdentityProviderFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/CustomOIDCIdentityProviderFactory.java new file mode 100644 index 00000000..714a64bf --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/CustomOIDCIdentityProviderFactory.java @@ -0,0 +1,28 @@ +package com.github.bcgov.keycloak.broker.oidc; + +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; + +/** @author Junmin Ahn */ +public class CustomOIDCIdentityProviderFactory extends OIDCIdentityProviderFactory { + + public static final String PROVIDER_ID = "oidc-custom"; + + @Override + public String getName() { + return "OpenID Connect v1.0 - Custom"; + } + + @Override + public OIDCIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new CustomOIDCIdentityProvider(session, new OIDCIdentityProviderConfig(model)); + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProvider.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProvider.java new file mode 100644 index 00000000..3615f646 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProvider.java @@ -0,0 +1,88 @@ +package com.github.bcgov.keycloak.broker.oidc; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; +import org.jboss.logging.Logger; +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.services.resources.IdentityBrokerService; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; + +/** @author Junmin Ahn */ +public class OverrideOIDCIdentityProvider extends OIDCIdentityProvider { + + private static final Logger logger = Logger.getLogger(OverrideOIDCIdentityProvider.class); + + public OverrideOIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { + super(session, config); + } + + @Override + public Response keycloakInitiatedBrowserLogout( + KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) { + if (getConfig().getLogoutUrl() == null || getConfig().getLogoutUrl().trim().equals("")) + return null; + + String idToken = getIDTokenForLogout(session, userSession); + if (idToken != null && getConfig().isBackchannelSupported()) { + backchannelLogout(userSession, idToken); + return null; + } + + String sessionId = userSession.getId(); + UriBuilder logoutUri = + UriBuilder.fromUri(getConfig().getLogoutUrl()).queryParam("state", sessionId); + String redirect = + RealmsResource.brokerUrl(uriInfo) + .path(IdentityBrokerService.class, "getEndpoint") + .path(OIDCEndpoint.class, "logoutResponse") + .build(realm.getName(), getConfig().getAlias()) + .toString(); + + if (idToken != null) { + logoutUri.queryParam("id_token_hint", idToken); + logoutUri.queryParam("post_logout_redirect_uri", redirect); + } else { + if (!isLegacyLogoutRedirectUriSupported()) { + logger.warn("no id_token found and legacy logout redirect uri not supported: " + redirect); + return null; + } + + logger.warn("no id_token found; use legacy redirect_uri query param: " + redirect); + logoutUri.queryParam("redirect_uri", redirect); + } + + return Response.status(302).location(logoutUri.build()).build(); + } + + private String getIDTokenForLogout(KeycloakSession session, UserSessionModel userSession) { + String tokenExpirationString = userSession.getNote(FEDERATED_TOKEN_EXPIRATION); + long exp = tokenExpirationString == null ? 0 : Long.parseLong(tokenExpirationString); + int currentTime = Time.currentTime(); + if (exp > 0 && currentTime > exp) { + String response = refreshTokenForLogout(session, userSession); + AccessTokenResponse tokenResponse = null; + try { + tokenResponse = JsonSerialization.readValue(response, AccessTokenResponse.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + return tokenResponse.getIdToken(); + } else { + return userSession.getNote(FEDERATED_ID_TOKEN); + } + } + + public boolean isLegacyLogoutRedirectUriSupported() { + return Boolean.valueOf(getConfig().getConfig().get("legacyLogoutRedirectUriSupported")); + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProviderFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProviderFactory.java new file mode 100644 index 00000000..3552e817 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProviderFactory.java @@ -0,0 +1,28 @@ +package com.github.bcgov.keycloak.broker.oidc; + +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; + +/** @author Junmin Ahn */ +public class OverrideOIDCIdentityProviderFactory extends OIDCIdentityProviderFactory { + + public static final String PROVIDER_ID = "oidc"; + + @Override + public String getName() { + return "OpenID Connect v1.0"; + } + + @Override + public OIDCIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new OverrideOIDCIdentityProvider(session, new OIDCIdentityProviderConfig(model)); + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/ext/endpoints/LegacyEndpoint.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/ext/endpoints/LegacyEndpoint.java new file mode 100644 index 00000000..8c45bae5 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/ext/endpoints/LegacyEndpoint.java @@ -0,0 +1,123 @@ +package com.github.bcgov.keycloak.protocol.oidc.ext.endpoints; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.ext.OIDCExtProvider; +import org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.urls.UrlType; + +import java.net.URI; + +/** @author Junmin Ahn */ +public class LegacyEndpoint + implements OIDCExtProvider, OIDCExtProviderFactory, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "legacy"; + + private EventBuilder event; + + private final KeycloakSession session; + + public LegacyEndpoint() { + // for reflection + this(null); + } + + public LegacyEndpoint(KeycloakSession session) { + this.session = session; + } + + /** + * This endpoint parses the query params and regenerates them to redirect back to the OIDC logout + * endpoint. `id_token_hint`, `state`, `ui_locales`, and `initiating_idp` are passed through as + * they are. + * + *

1. if `id_token_hint` exists, it sets the redirect uri to `post_logout_redirect_uri`.
+ * 2. if `id_token_hint` omitted, it sets the redirect uri to `redirect_uri` (legacy). + * + * @param deprecatedRedirectUri "redirect_uri" + * @param encodedIdToken "id_token_hint" + * @param postLogoutRedirectUri "post_logout_redirect_uri" + * @param state "state" + * @param uiLocales "ui_locales" + * @param initiatingIdp "initiating_idp" + * @return a redirect Response with the regenerated query params. + */ + @GET + @Path("/logout") + public Response logout( + @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String deprecatedRedirectUri, // deprecated + @QueryParam(OIDCLoginProtocol.ID_TOKEN_HINT) String encodedIdToken, + @QueryParam(OIDCLoginProtocol.POST_LOGOUT_REDIRECT_URI_PARAM) String postLogoutRedirectUri, + @QueryParam(OIDCLoginProtocol.STATE_PARAM) String state, + @QueryParam(OIDCLoginProtocol.UI_LOCALES_PARAM) String uiLocales, + @QueryParam(AuthenticationManager.INITIATING_IDP_PARAM) String initiatingIdp) { + String realmName = session.getContext().getRealm().getName(); + UriBuilder uriBuilder = + OIDCLoginProtocolService.logoutUrl(session.getContext().getUri(UrlType.FRONTEND)); + + if (encodedIdToken != null) { + uriBuilder = uriBuilder.queryParam(OIDCLoginProtocol.ID_TOKEN_HINT, encodedIdToken); + } + + if (state != null) { + uriBuilder = uriBuilder.queryParam(OIDCLoginProtocol.STATE_PARAM, state); + } + + if (uiLocales != null) { + uriBuilder = uriBuilder.queryParam(OIDCLoginProtocol.UI_LOCALES_PARAM, uiLocales); + } + + if (initiatingIdp != null) { + uriBuilder = uriBuilder.queryParam(AuthenticationManager.INITIATING_IDP_PARAM, initiatingIdp); + } + + String redirectUri = + postLogoutRedirectUri != null ? postLogoutRedirectUri : deprecatedRedirectUri; + + if (redirectUri != null) { + if (encodedIdToken != null) { + uriBuilder = + uriBuilder.queryParam(OIDCLoginProtocol.POST_LOGOUT_REDIRECT_URI_PARAM, redirectUri); + } else { + uriBuilder = uriBuilder.queryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri); + } + } + + URI redirect = uriBuilder.build(realmName); + + return Response.status(302).location(redirect).build(); + } + + @Override + public OIDCExtProvider create(KeycloakSession session) { + return new LegacyEndpoint(session); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void setEvent(EventBuilder event) { + this.event = event; + } + + @Override + public void close() { /* This is ok */ } + + @Override + public boolean isSupported() { + return true; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/mappers/ClaimOmitterMapper.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/mappers/ClaimOmitterMapper.java new file mode 100644 index 00000000..932649e7 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/mappers/ClaimOmitterMapper.java @@ -0,0 +1,108 @@ +package com.github.bcgov.keycloak.protocol.oidc.mappers; + +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.*; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.IDToken; + +import java.util.*; + +/** @author Junmin Ahn */ +public class ClaimOmitterMapper extends AbstractOIDCProtocolMapper + implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper { + + public static final String IDP_ALIASES = "identity_provider_aliases"; + public static final String TOKEN_CLAIM_NAMES = "token_claim_names"; + + private static final List configProperties = + new ArrayList<>(); + + static { + ProviderConfigProperty config = new ProviderConfigProperty(); + config.setName(IDP_ALIASES); + config.setLabel("Identity Provider Aliases"); + config.setHelpText(""); + config.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(config); + + config = new ProviderConfigProperty(); + config.setName(TOKEN_CLAIM_NAMES); + config.setLabel("Token Claim Names"); + config.setHelpText("List of the token claim names to remove from the token."); + config.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(config); + + OIDCAttributeMapperHelper.addIncludeInTokensConfig(configProperties, ClaimOmitterMapper.class); + } + + public static final String PROVIDER_ID = "omit-claim-by-idp-mapper"; + + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Omit Claims By IDPs"; + } + + @Override + public String getDisplayCategory() { + return TOKEN_MAPPER_CATEGORY; + } + + @Override + public String getHelpText() { + return "Omit one or multiple token claims"; + } + + @Override + protected void setClaim( + IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession) { + String sessionIdpAlias = userSession.getNotes().get("identity_provider"); + String idpAliases = mappingModel.getConfig().get(IDP_ALIASES); + String[] idpAliasArr = idpAliases == null ? new String[0] : idpAliases.split(" "); + + if (idpAliasArr.length > 0) { + if (sessionIdpAlias == null || sessionIdpAlias.trim().isEmpty()) return; + if (!Arrays.asList(idpAliasArr).contains(sessionIdpAlias)) return; + } + + String tokenClaims = mappingModel.getConfig().get(TOKEN_CLAIM_NAMES); + String[] tokenClaimArr = tokenClaims == null ? new String[0] : tokenClaims.split(" "); + + if (tokenClaimArr.length == 0) return; + + Map otherClaims = token.getOtherClaims(); + + for (String claim : tokenClaimArr) { + otherClaims.put(claim, ""); + } + } + + public static ProtocolMapperModel create( + String name, boolean accessToken, boolean idToken, boolean userInfo) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map config = new HashMap<>(); + if (accessToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + if (userInfo) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "true"); + mapper.setConfig(config); + return mapper; + } + + @Override + public int getPriority() { + return 99; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/mappers/IDPUserinfoMapper.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/mappers/IDPUserinfoMapper.java new file mode 100644 index 00000000..617c4a56 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/oidc/mappers/IDPUserinfoMapper.java @@ -0,0 +1,293 @@ +package com.github.bcgov.keycloak.protocol.oidc.mappers; + +import com.fasterxml.jackson.databind.JsonNode; + +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.jboss.logging.Logger; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureProvider; +import org.keycloak.jose.JOSE; +import org.keycloak.jose.JOSEParser; +import org.keycloak.jose.jwe.JWE; +import org.keycloak.jose.jwe.JWEException; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.keys.loader.PublicKeyStorageManager; +import org.keycloak.models.*; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.*; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.IDToken; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.management.RuntimeErrorException; + +/** @author Junmin Ahn */ +public class IDPUserinfoMapper extends AbstractOIDCProtocolMapper + implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper { + + private static final Logger logger = Logger.getLogger(IDPUserinfoMapper.class); + + private static final List configProperties = new ArrayList(); + + public static final String CLAIM_VALUE = "claim.value"; + + public static final String USER_ATTRIBUTES = "userAttributes"; + + public static final String SIGNATURE_EXPECTED = "signatureExpected"; + + public static final String ENCRYPTION_EXPECTED = "encryptionExpected"; + + static { + configProperties.add(new ProviderConfigProperty(SIGNATURE_EXPECTED, "Signature Expected", + "Whether the signature should be verified", ProviderConfigProperty.BOOLEAN_TYPE, false)); + + configProperties.add(new ProviderConfigProperty(ENCRYPTION_EXPECTED, "Encryption Expected", + "Whether the userinfo response requires decryption", ProviderConfigProperty.BOOLEAN_TYPE, + false)); + + configProperties.add(new ProviderConfigProperty(USER_ATTRIBUTES, "User Attributes", + "List of comma separated user attributes returned from IDP userinfo endpoint. Example: email,firstName,lastName", + + ProviderConfigProperty.STRING_TYPE, null)); + OIDCAttributeMapperHelper.addIncludeInTokensConfig(configProperties, IDPUserinfoMapper.class); + } + + public static final String PROVIDER_ID = "oidc-idp-userinfo-mapper"; + + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "IDP Userinfo"; + } + + @Override + public String getDisplayCategory() { + return TOKEN_MAPPER_CATEGORY; + } + + @Override + public String getHelpText() { + return "Include the upstream IDP user info into the token."; + } + + private static AccessTokenResponse parseTokenString(String tokenString) { + try { + return JsonSerialization.readValue(tokenString, AccessTokenResponse.class); + } catch (Exception e) { + return null; + } + } + + private static JsonNode parseJson(String json) { + try { + return JsonSerialization.mapper.readValue(json, JsonNode.class); + } catch (Exception e) { + return null; + } + } + + @Override + protected void setClaim( + IDToken token, + ProtocolMapperModel mappingModel, + UserSessionModel userSession, + KeycloakSession keycloakSession, + ClientSessionContext clientSessionCtx) { + + String idp = userSession.getNotes().get("identity_provider"); + RealmModel realm = userSession.getRealm(); + IdentityProviderModel identityProviderConfig = realm.getIdentityProviderByAlias(idp); + JsonNode userInfo; + JWSInput jws; + + if (identityProviderConfig.isStoreToken()) { + IdentityProviderModel identityProviderModel = realm.getIdentityProviderByAlias(idp); + String userInfoUrl = identityProviderModel.getConfig().get("userInfoUrl"); + + if (userInfoUrl != null) { + FederatedIdentityModel identity = keycloakSession.users().getFederatedIdentity(realm, userSession.getUser(), + idp); + String brokerToken = identity.getToken(); + AccessTokenResponse brokerAccessToken = parseTokenString(brokerToken); + String userinfoResponse; + + try { + userinfoResponse = callUserInfo(userInfoUrl, brokerAccessToken.getToken()); + } catch (IOException e) { + throw new IdentityBrokerException("Failed to call userinfo endpoint"); + } + + Boolean encryptionExpected = Boolean.parseBoolean(mappingModel.getConfig().get(ENCRYPTION_EXPECTED)); + + if (encryptionExpected) { + JOSE joseToken = JOSEParser.parse(userinfoResponse); + if (joseToken instanceof JWE) { + // encrypted JWE token + JWE jwe = (JWE) joseToken; + try { + KeyWrapper key; + if (jwe.getHeader().getKeyId() == null) { + key = keycloakSession.keys().getActiveKey(keycloakSession.getContext().getRealm(), KeyUse.ENC, + jwe.getHeader().getRawAlgorithm()); + } else { + key = keycloakSession.keys().getKey(keycloakSession.getContext().getRealm(), jwe.getHeader().getKeyId(), + KeyUse.ENC, + jwe.getHeader().getRawAlgorithm()); + } + if (key == null || key.getPrivateKey() == null) { + throw new IdentityBrokerException("Private key not found in the realm to decrypt token algorithm " + + jwe.getHeader().getRawAlgorithm()); + } + + jwe.getKeyStorage().setDecryptionKey(key.getPrivateKey()); + jwe.verifyAndDecodeJwe(); + userinfoResponse = new String(jwe.getContent(), StandardCharsets.UTF_8); + } catch (JWEException e) { + throw new IdentityBrokerException("Failed to decrypt userinfo JWT", e); + } + } + } + + Boolean signatureExpected = Boolean.parseBoolean(mappingModel.getConfig().get(SIGNATURE_EXPECTED)); + + if (signatureExpected) { + + OIDCIdentityProviderConfig oidcIdpConfig = new OIDCIdentityProviderConfig(identityProviderConfig); + + JOSE joseToken = JOSEParser.parse(userinfoResponse); + + // common signed JWS token + jws = (JWSInput) joseToken; + + // verify signature of the JWS + if (!verify(keycloakSession, oidcIdpConfig, jws)) { + throw new IdentityBrokerException("Failed to verify userinfo JWT signature"); + } + + try { + userInfo = JsonSerialization.readValue(new String(jws.getContent(), StandardCharsets.UTF_8), + JsonNode.class); + } catch (IOException e) { + throw new IdentityBrokerException("Failed to parse userinfo JWT", e); + } + } else { + userInfo = parseJson(userinfoResponse); + } + + if (userInfo != null) { + // process string value of user attributes + String userAttributes = mappingModel.getConfig().get(USER_ATTRIBUTES); + String[] userAttributesArr = userAttributes == null ? new String[0] : userAttributes.split(","); + + if (userAttributesArr.length > 0) { + Map otherClaims = token.getOtherClaims(); + for (String userAttribute : userAttributesArr) { + otherClaims.put(userAttribute.trim(), userInfo.get(userAttribute.trim())); + } + } + } else { + logger.error("The payload received from userinfo is null"); + } + + } else { + logger.error("Identity Provider [" + idp + "] does not have userinfo URL."); + } + } else { + logger.error("Identity Provider [" + idp + "] does not store tokens."); + } + } + + public String callUserInfo(String userInfoUrl, String brokerToken) throws IOException { + CloseableHttpClient httpClient = HttpClients.createDefault(); + try { + CloseableHttpResponse response; + HttpGet getRqst = new HttpGet(userInfoUrl); + getRqst.addHeader("Authorization", "Bearer " + brokerToken); + response = httpClient.execute(getRqst); + int status = response.getStatusLine().getStatusCode(); + if (!(status >= 200 && status < 400)) { + throw new RuntimeException("Invalid status received from userinfo endpoint= " + status); + } + try { + HttpEntity entity = response.getEntity(); + return EntityUtils.toString(entity); + } finally { + response.close(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + httpClient.close(); + } + } + + public static ProtocolMapperModel create( + String name, String tokenClaimName, boolean accessToken, boolean idToken) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map config = new HashMap<>(); + config.put(OIDCAttributeMapperHelper.TOKEN_CLAIM_NAME, tokenClaimName); + if (accessToken) + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true"); + if (idToken) + config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + mapper.setConfig(config); + return mapper; + } + + protected boolean verify(KeycloakSession session, OIDCIdentityProviderConfig idpConfig, JWSInput jws) { + + try { + KeyWrapper key = PublicKeyStorageManager.getIdentityProviderKeyWrapper(session, session.getContext().getRealm(), + idpConfig, + jws); + + if (key == null) { + logger.errorf("Failed to verify userinfo JWT signature, public key not found for algorithm %s", + jws.getHeader().getRawAlgorithm()); + return false; + } + String algorithm = jws.getHeader().getRawAlgorithm(); + if (key.getAlgorithm() == null) { + key.setAlgorithm(algorithm); + } + SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, algorithm); + if (signatureProvider == null) { + logger.errorf("Failed to verify userinfo JWT, signature provider not found for algorithm %s", algorithm); + return false; + } + + return signatureProvider.verifier(key).verify(jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), + jws.getSignature()); + } catch (Exception e) { + logger.error("Failed to verify signature of userinfo JWT", e); + return false; + } + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/saml/mappers/ClientRoleListMapper.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/saml/mappers/ClientRoleListMapper.java new file mode 100644 index 00000000..8af48539 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/saml/mappers/ClientRoleListMapper.java @@ -0,0 +1,152 @@ +package com.github.bcgov.keycloak.protocol.saml.mappers; + +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; +import org.keycloak.models.*; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.protocol.saml.mappers.AbstractSAMLProtocolMapper; +import org.keycloak.protocol.saml.mappers.AttributeStatementHelper; +import org.keycloak.protocol.saml.mappers.SAMLRoleListMapper; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +/** @author Junmin Ahn */ +public class ClientRoleListMapper extends AbstractSAMLProtocolMapper implements SAMLRoleListMapper { + public static final String PROVIDER_ID = "saml-client-role-list-mapper"; + public static final String SINGLE_ROLE_ATTRIBUTE = "single"; + private static final List configProperties = new ArrayList<>(); + + static { + ProviderConfigProperty property; + property = new ProviderConfigProperty(); + property.setName(AttributeStatementHelper.SAML_ATTRIBUTE_NAME); + property.setLabel("Role attribute name"); + property.setDefaultValue("Role"); + property.setHelpText( + "Name of the SAML attribute you want to put your roles into. i.e. 'Role', 'memberOf'."); + configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(AttributeStatementHelper.FRIENDLY_NAME); + property.setLabel(AttributeStatementHelper.FRIENDLY_NAME_LABEL); + property.setHelpText(AttributeStatementHelper.FRIENDLY_NAME_HELP_TEXT); + configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(AttributeStatementHelper.SAML_ATTRIBUTE_NAMEFORMAT); + property.setLabel("SAML Attribute NameFormat"); + property.setHelpText( + "SAML Attribute NameFormat. Can be basic, URI reference, or unspecified."); + + List types = new ArrayList(3); + types.add(AttributeStatementHelper.BASIC); + types.add(AttributeStatementHelper.URI_REFERENCE); + types.add(AttributeStatementHelper.UNSPECIFIED); + property.setType(ProviderConfigProperty.LIST_TYPE); + property.setOptions(types); + configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(SINGLE_ROLE_ATTRIBUTE); + property.setLabel("Single Role Attribute"); + property.setType(ProviderConfigProperty.BOOLEAN_TYPE); + property.setDefaultValue("true"); + property.setHelpText( + "If true, all roles will be stored under one attribute with multiple attribute values."); + configProperties.add(property); + } + + @Override + public String getDisplayCategory() { + return "Client Role Mapper"; + } + + @Override + public String getDisplayType() { + return "Client role list"; + } + + @Override + public String getHelpText() { + return "This mapper stores client-level roles. You can also specify the attribute name i.e. 'Client Roles' or 'memberOf' being examples."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void mapRoles( + AttributeStatementType roleAttributeStatement, + ProtocolMapperModel mappingModel, + KeycloakSession session, + UserSessionModel userSession, + ClientSessionContext clientSessionCtx) { + String single = mappingModel.getConfig().get(SINGLE_ROLE_ATTRIBUTE); + boolean singleAttribute = Boolean.parseBoolean(single); + + AtomicReference singleAttributeType = new AtomicReference<>(null); + + List allClientRoleNames = + userSession + .getUser() + .getClientRoleMappingsStream(clientSessionCtx.getClientSession().getClient()) + .map(RoleModel::getName) + .toList(); + + for (String roleName : allClientRoleNames) { + AttributeType attributeType; + if (singleAttribute) { + if (singleAttributeType.get() == null) { + singleAttributeType.set(AttributeStatementHelper.createAttributeType(mappingModel)); + roleAttributeStatement.addAttribute( + new AttributeStatementType.ASTChoiceType(singleAttributeType.get())); + } + attributeType = singleAttributeType.get(); + } else { + attributeType = AttributeStatementHelper.createAttributeType(mappingModel); + roleAttributeStatement.addAttribute( + new AttributeStatementType.ASTChoiceType(attributeType)); + } + + attributeType.addAttributeValue(roleName); + } + } + + public static ProtocolMapperModel create( + String name, + String samlAttributeName, + String nameFormat, + String friendlyName, + boolean singleAttribute) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(SamlProtocol.LOGIN_PROTOCOL); + + Map config = new HashMap<>(); + config.put(AttributeStatementHelper.SAML_ATTRIBUTE_NAME, samlAttributeName); + if (friendlyName != null) { + config.put(AttributeStatementHelper.FRIENDLY_NAME, friendlyName); + } + if (nameFormat != null) { + config.put(AttributeStatementHelper.SAML_ATTRIBUTE_NAMEFORMAT, nameFormat); + } + + config.put(SINGLE_ROLE_ATTRIBUTE, Boolean.toString(singleAttribute)); + mapper.setConfig(config); + + return mapper; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/saml/mappers/StatementAttributeOmitterMapper.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/saml/mappers/StatementAttributeOmitterMapper.java new file mode 100644 index 00000000..3a300b28 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/protocol/saml/mappers/StatementAttributeOmitterMapper.java @@ -0,0 +1,115 @@ +package com.github.bcgov.keycloak.protocol.saml.mappers; + +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.protocol.saml.mappers.AbstractSAMLProtocolMapper; +import org.keycloak.protocol.saml.mappers.AttributeStatementHelper; +import org.keycloak.protocol.saml.mappers.SAMLAttributeStatementMapper; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** @author Junmin Ahn */ +public class StatementAttributeOmitterMapper extends AbstractSAMLProtocolMapper + implements SAMLAttributeStatementMapper { + + public static final String IDP_ALIASES = "identity_provider_aliases"; + public static final String STATEMENT_ATTRIBUTE_NAMES = "statement_attribute_names"; + + public static final String PROVIDER_ID = "saml-omit-statement-attributes-by-idp-mapper"; + private static final List configProperties = new ArrayList<>(); + + static { + ProviderConfigProperty config = new ProviderConfigProperty(); + config.setName(IDP_ALIASES); + config.setLabel("Identity Provider Aliases"); + config.setHelpText(""); + config.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(config); + + config = new ProviderConfigProperty(); + config.setName(STATEMENT_ATTRIBUTE_NAMES); + config.setLabel("Statement Attribute Names"); + config.setHelpText("List of the statement attribute names to remove from the token."); + config.setType(ProviderConfigProperty.STRING_TYPE); + configProperties.add(config); + } + + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Omit Statement Attributes By IDPs"; + } + + @Override + public String getDisplayCategory() { + return AttributeStatementHelper.ATTRIBUTE_STATEMENT_CATEGORY; + } + + @Override + public String getHelpText() { + return "Omit one or multiple statement attributes"; + } + + public void transformAttributeStatement( + AttributeStatementType attributeStatement, + ProtocolMapperModel mappingModel, + KeycloakSession session, + UserSessionModel userSession, + AuthenticatedClientSessionModel clientSession) { + String sessionIdpAlias = userSession.getNotes().get("identity_provider"); + String idpAliases = mappingModel.getConfig().get(IDP_ALIASES); + String[] idpAliasArr = idpAliases == null ? new String[0] : idpAliases.split(" "); + + if (idpAliasArr.length > 0) { + if (sessionIdpAlias == null || sessionIdpAlias.trim().isEmpty()) return; + if (!Arrays.asList(idpAliasArr).contains(sessionIdpAlias)) return; + } + + String statementAttributes = mappingModel.getConfig().get(STATEMENT_ATTRIBUTE_NAMES); + String[] statementAttributeArr = + statementAttributes == null ? new String[0] : statementAttributes.split(" "); + + if (statementAttributeArr.length == 0) return; + + List attributes = attributeStatement.getAttributes(); + + for (int i = attributes.size(); i-- > 0; ) { + AttributeStatementType.ASTChoiceType attribute = attributes.get(i); + String name = attribute.getAttribute().getName(); + for (String statAttrName : statementAttributeArr) { + if (statAttrName.equals(name)) { + attributeStatement.removeAttribute(attribute); + break; + } + } + } + } + + public static ProtocolMapperModel create(String name) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(SamlProtocol.LOGIN_PROTOCOL); + return mapper; + } + + @Override + public int getPriority() { + return 99; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubIdentityProvider.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubIdentityProvider.java new file mode 100644 index 00000000..6d422ceb --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubIdentityProvider.java @@ -0,0 +1,125 @@ +package com.github.bcgov.keycloak.social.github; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; +import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.broker.provider.util.SimpleHttp; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.KeycloakSession; +import org.keycloak.social.github.GitHubIdentityProvider; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** @author Junmin Ahn */ +public class CustomGitHubIdentityProvider extends GitHubIdentityProvider { + + public static final String USER_ORGS_URL = "https://api.github.com/user/orgs"; + public static final String DEFAULT_SCOPE = "user:email user:profile read:org"; + + public CustomGitHubIdentityProvider( + KeycloakSession session, OAuth2IdentityProviderConfig config) { + super(session, config); + } + + @Override + protected BrokeredIdentityContext extractIdentityFromProfile( + EventBuilder event, JsonNode profile) { + String id = getJsonProperty(profile, "id"); + String name = getJsonProperty(profile, "name"); + String email = getJsonProperty(profile, "email"); + + BrokeredIdentityContext user = new BrokeredIdentityContext(id); + + user.setUsername(id); + user.setName(name); + user.setEmail(email); + user.setIdpConfig(getConfig()); + user.setIdp(this); + + AbstractJsonUserAttributeMapper.storeUserProfileForMapper( + user, profile, getConfig().getAlias()); + + return user; + } + + @Override + protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { + try { + OAuth2IdentityProviderConfig config = getConfig(); + String targetOrg = config.getConfig().get("githubOrg"); + String[] targetOrgs = targetOrg == null ? new String[0] : targetOrg.split(" "); + boolean targetOrgRequired = Boolean.parseBoolean(config.getConfig().get("githubOrgRequired")); + boolean orgVerified = false; + List myOrgs = new ArrayList<>(); + + if (targetOrgs.length > 0) { + JsonNode userOrgs = + SimpleHttp.doGet(USER_ORGS_URL, session) + .header("Authorization", "Bearer " + accessToken) + .asJson(); + + for (String torg : targetOrgs) { + for (JsonNode org : userOrgs) { + String orgName = getJsonProperty(org, "login"); + if (orgName.equals(torg)) { + myOrgs.add(torg); + orgVerified = true; + } + } + } + + if (targetOrgRequired && !orgVerified) + throw new IdentityBrokerException("User does not belong to the target GitHub Org"); + } + + JsonNode profile = + SimpleHttp.doGet(PROFILE_URL, session) + .header("Authorization", "Bearer " + accessToken) + .asJson(); + + ((ObjectNode) profile).put("org_verified", String.valueOf(orgVerified)); + ((ObjectNode) profile).put("orgs", String.join(" ", myOrgs)); + BrokeredIdentityContext user = extractIdentityFromProfile(null, profile); + + if (user.getEmail() == null) { + user.setEmail(searchEmail(accessToken)); + } + + return user; + } catch (Exception e) { + throw new IdentityBrokerException("Could not obtain user profile from GitHub.", e); + } + } + + private String searchEmail(String accessToken) { + try { + ArrayNode emails = + (ArrayNode) + SimpleHttp.doGet(EMAIL_URL, session) + .header("Authorization", "Bearer " + accessToken) + .asJson(); + + Iterator loop = emails.elements(); + while (loop.hasNext()) { + JsonNode mail = loop.next(); + if (mail.get("primary").asBoolean()) { + return getJsonProperty(mail, "email"); + } + } + } catch (Exception e) { + throw new IdentityBrokerException("Could not obtain user email from GitHub.", e); + } + throw new IdentityBrokerException("Primary email from GitHub is not found."); + } + + @Override + protected String getDefaultScopes() { + return DEFAULT_SCOPE; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubIdentityProviderFactory.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubIdentityProviderFactory.java new file mode 100644 index 00000000..6b661aa8 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubIdentityProviderFactory.java @@ -0,0 +1,41 @@ +package com.github.bcgov.keycloak.social.github; + +import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.social.github.GitHubIdentityProvider; +import org.keycloak.social.github.GitHubIdentityProviderFactory; + +import java.util.List; + +/** @author Junmin Ahn */ +public class CustomGitHubIdentityProviderFactory extends GitHubIdentityProviderFactory { + public static final String PROVIDER_ID = "github-custom"; + + @Override + public String getName() { + return "GitHub - Custom"; + } + + @Override + public GitHubIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new CustomGitHubIdentityProvider(session, new OAuth2IdentityProviderConfig(model)); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create().property() + .name("githubOrg").label("Github Org").helpText("Github organization the user must belong to.") + .type(ProviderConfigProperty.STRING_TYPE).add().property() + .name("githubOrgRequired").label("Github Org Required") + .helpText("Check if the user must belong to the target GitHub organization.") + .type(ProviderConfigProperty.BOOLEAN_TYPE).add().build(); + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubUserAttributeMapper.java b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubUserAttributeMapper.java new file mode 100644 index 00000000..c253077a --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/java/com/github/bcgov/keycloak/social/github/CustomGitHubUserAttributeMapper.java @@ -0,0 +1,20 @@ +package com.github.bcgov.keycloak.social.github; + +import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; + +/** @author Junmin Ahn */ +public class CustomGitHubUserAttributeMapper extends AbstractJsonUserAttributeMapper { + + public static final String PROVIDER_ID = "github-custom-user-attribute-mapper"; + private static final String[] cp = new String[] {CustomGitHubIdentityProviderFactory.PROVIDER_ID}; + + @Override + public String[] getCompatibleProviders() { + return cp; + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/docker/keycloak/extensions-24/services/src/main/resources/META-INF/jboss-deployment-structure.xml b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/jboss-deployment-structure.xml new file mode 100644 index 00000000..6cef78cf --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/jboss-deployment-structure.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 00000000..e56d03de --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1,8 @@ +com.github.bcgov.keycloak.authenticators.IdentityProviderStopAuthenticatorFactory +com.github.bcgov.keycloak.authenticators.CookieStopAuthenticatorFactory +com.github.bcgov.keycloak.authenticators.ClientLoginAuthenticatorFactory +com.github.bcgov.keycloak.authenticators.ClientLoginRoleBindingFactory +com.github.bcgov.keycloak.authenticators.UserSessionRemoverFactory +com.github.bcgov.keycloak.authenticators.UserAttributeAuthenticatorFactory +com.github.bcgov.keycloak.authenticators.broker.IdpDeleteUserIfDuplicateAuthenticatorFactory +com.github.bcgov.keycloak.authenticators.browser.IdentityProviderStopFormFactory diff --git a/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory new file mode 100644 index 00000000..14f202e9 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory @@ -0,0 +1,2 @@ +com.github.bcgov.keycloak.broker.oidc.CustomOIDCIdentityProviderFactory +com.github.bcgov.keycloak.broker.oidc.OverrideOIDCIdentityProviderFactory diff --git a/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper new file mode 100644 index 00000000..174197e1 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper @@ -0,0 +1 @@ +com.github.bcgov.keycloak.social.github.CustomGitHubUserAttributeMapper diff --git a/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory new file mode 100644 index 00000000..1483d4d5 --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.broker.social.SocialIdentityProviderFactory @@ -0,0 +1 @@ +com.github.bcgov.keycloak.social.github.CustomGitHubIdentityProviderFactory diff --git a/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper new file mode 100644 index 00000000..c622184a --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -0,0 +1,4 @@ +com.github.bcgov.keycloak.protocol.oidc.mappers.IDPUserinfoMapper +com.github.bcgov.keycloak.protocol.oidc.mappers.ClaimOmitterMapper +com.github.bcgov.keycloak.protocol.saml.mappers.ClientRoleListMapper +com.github.bcgov.keycloak.protocol.saml.mappers.StatementAttributeOmitterMapper diff --git a/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory new file mode 100644 index 00000000..558cdc5d --- /dev/null +++ b/docker/keycloak/extensions-24/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory @@ -0,0 +1 @@ +com.github.bcgov.keycloak.protocol.oidc.ext.endpoints.LegacyEndpoint diff --git a/docker/keycloak/extensions-24/themes/pom.xml b/docker/keycloak/extensions-24/themes/pom.xml new file mode 100644 index 00000000..bdd546ba --- /dev/null +++ b/docker/keycloak/extensions-24/themes/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + + com.github.bcgov.keycloak + extensions-parent + 1.0.0 + + + bcgov-themes + pom + + + + org.apache.maven.plugins + maven-assembly-plugin + + + src/assembly/bin.xml + + ${project.artifactId}-${project.version} + false + + + + package + + single + + + + + + + diff --git a/docker/keycloak/extensions-24/themes/src/assembly/bin.xml b/docker/keycloak/extensions-24/themes/src/assembly/bin.xml new file mode 100644 index 00000000..25e1a2ee --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/assembly/bin.xml @@ -0,0 +1,11 @@ + + bin + + tar.gz + + + + src/main/resources + + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/META-INF/keycloak-themes.json b/docker/keycloak/extensions-24/themes/src/main/resources/META-INF/keycloak-themes.json new file mode 100644 index 00000000..c6cdd8de --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/META-INF/keycloak-themes.json @@ -0,0 +1,3 @@ +{ + "themes": [] +} diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-custom-ext.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-custom-ext.html new file mode 100644 index 00000000..af45dfd2 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-custom-ext.html @@ -0,0 +1,14 @@ +

+ +
+ +
+ GitHub Org +
+
+ +
+ +
+ GitHub Org Required +
diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-custom.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-custom.html new file mode 100644 index 00000000..63272a2a --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-github-custom.html @@ -0,0 +1 @@ +
diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-keycloak-oidc-ext.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-keycloak-oidc-ext.html new file mode 100644 index 00000000..e69de29b diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-base.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-base.html new file mode 100644 index 00000000..3a38d946 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-base.html @@ -0,0 +1,391 @@ +
+ + + + +
+
+
+ +
+ +
+ {{:: 'redirect-uri.tooltip' | translate}} +
+
+
+
+ +
+ +
+ {{:: 'identity-provider.alias.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.display-name.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.enabled.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.store-tokens.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.stored-tokens-readable.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'trust-email.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'link-only.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'hide-on-login-page.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'gui-order.tooltip' | translate}} +
+
+ +
+
+ +
+
+ {{:: 'first-broker-login-flow.tooltip' | translate}} +
+
+ +
+
+ +
+
+ {{:: 'post-broker-login-flow.tooltip' | translate}} +
+
+ +
+
+ +
+
+ {{:: 'sync-mode.tooltip' | translate}} +
+
+
+ {{:: 'openid-connect-config' | translate}} {{:: 'openid-connect-config.tooltip' | translate}} +
+ +
+ +
+ {{:: 'authorization-url.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'loginHint.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'uiLocales.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'token-url.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.logout-url.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'backchannel-logout.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.disableUserInfo.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'user-info-url.tooltip' | translate}} +
+
+ +
+
+ +
+
+ {{:: 'client-auth.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.client-id.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'client-secret.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'client-assertion-signing-algorithm.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'issuer.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.default-scopes.tooltip' | translate}} +
+
+ +
+
+ +
+
+ {{:: 'prompt.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'accepts-prompt-none-forward-from-client.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.validate-signatures.tooltip' | translate}} +
+ +
+ +
+ +
+ +
+ {{:: 'identity-provider.use-jwks-url.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'identity-provider.jwks-url.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'identity-provider.validating-public-key.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'identity-provider.validating-public-key-id.tooltip' | translate}} +
+ +
+ +
+ +
+ +
+ {{:: 'pkce-enabled.tooltip' | translate}} +
+ +
+ +
+
+ +
+
+ {{:: 'pkce-method.tooltip' | translate}} +
+ +
+ +
+ +
+ {{:: 'identity-provider.allowed-clock-skew.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'identity-provider.forwarded-query-parameters.tooltip' | translate}} +
+
+
+
+
+
+ {{:: 'import-external-idp-config' | translate}} {{:: 'import-external-idp-config.tooltip' | translate}} +
+ +
+ +
+ {{:: 'identity-provider.import-from-url.tooltip' | translate}} +
+
+ +
+ +
+
+
+ + {{:: 'identity-provider.import-from-file.tooltip' | translate}} +
+
+ + +
+ + {{files[0].name}} + +
+
+ +
+ +
+
+
+
+ +
+
+ + +
+
+
+
+ + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-custom-ext.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-custom-ext.html new file mode 100644 index 00000000..e69de29b diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-custom.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-custom.html new file mode 100644 index 00000000..544175f8 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-custom.html @@ -0,0 +1 @@ +
diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-ext.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-ext.html new file mode 100644 index 00000000..c75aae21 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc-ext.html @@ -0,0 +1,14 @@ +
+ +
+ +
+ Does the external IDP support legacy logout redirect URI (redirect_uri)? +
+
+ +
+ +
+ IDP tooltip to show on the login screen. +
diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html new file mode 100644 index 00000000..544175f8 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html @@ -0,0 +1 @@ +
diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login-no-brand/login/theme.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login-no-brand/login/theme.properties new file mode 100644 index 00000000..684f0dbc --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login-no-brand/login/theme.properties @@ -0,0 +1,3 @@ +parent=bcgov-idp-login +kcShowHeader=false +kcShowFooter=false diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login/login/login.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login/login/login.ftl new file mode 100644 index 00000000..95553f59 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login/login/login.ftl @@ -0,0 +1,20 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=true displayInfo=false; section> + <#if section = "header"> + ${msg("loginAccountTitle")} + <#elseif section = "socialProviders"> + <#if social.providers??> +
+ +
+ + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login/login/theme.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login/login/theme.properties new file mode 100644 index 00000000..9b4b65e8 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-login/login/theme.properties @@ -0,0 +1 @@ +parent=bcgov diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper-no-header-title/login/theme.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper-no-header-title/login/theme.properties new file mode 100644 index 00000000..20a0a24d --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper-no-header-title/login/theme.properties @@ -0,0 +1,2 @@ +parent=bcgov-idp-stopper +kcShowHeaderTitle=false diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/login-original.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/login-original.ftl new file mode 100644 index 00000000..21f3f956 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/login-original.ftl @@ -0,0 +1,105 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section> + <#if section = "header"> + ${msg("loginAccountTitle")} + <#elseif section = "form"> +
+
+ <#if realm.password> +
+ <#if !usernameHidden??> +
+ + + + + <#if messagesPerField.existsError('username','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + + + +
+ + +
+ + + + + <#if usernameHidden?? && messagesPerField.existsError('username','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + + + +
+ +
+
+ <#if realm.rememberMe && !usernameHidden??> +
+ +
+ +
+
+ <#if realm.resetPasswordAllowed> + ${msg("doForgotPassword")} + +
+ +
+ +
+ value="${auth.selectedCredential}"/> + +
+
+ +
+ +
+ <#elseif section = "info" > + <#if realm.password && realm.registrationAllowed && !registrationDisabled??> +
+
+ ${msg("noAccount")} ${msg("doRegister")} +
+
+ + <#elseif section = "socialProviders" > + <#if realm.password && social.providers??> +
+
+

${msg("identity-provider-login-label")}

+ + +
+ + + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/login.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/login.ftl new file mode 100644 index 00000000..d0e27c5a --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/login.ftl @@ -0,0 +1,45 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=true displayInfo=false; section> + <#if section = "header"> + ${msg("loginAccountTitle")} + <#elseif section = "socialProviders"> + <#if social.providers?? && (login.username)??> +
+ +
+ + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/theme.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/theme.properties new file mode 100644 index 00000000..89e24031 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-idp-stopper/login/theme.properties @@ -0,0 +1,2 @@ +parent=bcgov +kcShowHeaderTitle=true diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-no-brand/login/theme.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-no-brand/login/theme.properties new file mode 100644 index 00000000..d1efc041 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov-no-brand/login/theme.properties @@ -0,0 +1,3 @@ +parent=bcgov +kcShowHeader=false +kcShowFooter=false diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/html/email-verification.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/html/email-verification.ftl new file mode 100644 index 00000000..faf2daed --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/html/email-verification.ftl @@ -0,0 +1,5 @@ + + +${msg("emailVerificationBodyHtml",link, linkExpiration, (realm.displayName)!realmName, (user.firstName)!user.username)?no_esc} + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/messages/messages_en.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/messages/messages_en.properties new file mode 100644 index 00000000..b8f761ca --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/messages/messages_en.properties @@ -0,0 +1,3 @@ +emailVerificationSubject=Verification email for Single Sign-On +emailVerificationBody=Hi {3},\n\nA {2} Single Sign-On account with this email address has been created. If this was you, click the link below to verify your email address.\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you did not create this account, just ignore this message.\n\n\n{2} Single Sign-On +emailVerificationBodyHtml=

Hi, {3}

A {2} Single Sign-On account with this email address has been created. If this was you, click the link below to verify your email address.

Link to email address verification

This link will expire within {1} minutes.

If you did not create this account, just ignore this message.

{2} Single Sign-On diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/theme.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/theme.properties new file mode 100644 index 00000000..8f83cc02 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/email/theme.properties @@ -0,0 +1 @@ +parent=base diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/logout-confirm-original.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/logout-confirm-original.ftl new file mode 100644 index 00000000..6c0b4e97 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/logout-confirm-original.ftl @@ -0,0 +1,38 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("logoutConfirmTitle")} + <#elseif section = "form"> +
+

${msg("logoutConfirmHeader")}

+ +
+ +
+
+
+
+
+ +
+ +
+ +
+
+ +
+ <#if logoutConfirm.skipLink> + <#else> + <#if (client.baseUrl)?has_content> +

${kcSanitize(msg("backToApplication"))?no_esc}

+ + +
+ +
+
+ + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/logout-confirm.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/logout-confirm.ftl new file mode 100644 index 00000000..bc510a8d --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/logout-confirm.ftl @@ -0,0 +1,23 @@ + + + + + + + + +
+ + +
+ + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/messages/messages_en.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/messages/messages_en.properties new file mode 100644 index 00000000..4a29a6e9 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/messages/messages_en.properties @@ -0,0 +1,9 @@ +totpBrowserPluginTitle=Desktop or Browser Authenticator application +totpMobileLbl=Mobile +loginTotpTitle=Authenticator Setup +loginTotpStep1=Install one of the following applications on your mobile, desktop or browser: +configureTotpMessage=You need to set up either a mobile, desktop or browser authenticator to activate your account. +missingTotpMessage=Please specify One-time code. +kcLabelWrapperClass=col-md-4 +invalidFederatedIdentityActionMessage=An unexpected error has occurred or you are already logged into IDIR or BCeID and you need to logout or use a fresh browser to continue. Visit here to learn more. +differentUserAuthenticated=You have an active session with a provider . Please sign out to proceed further. diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/css/bcsans-20221128.css b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/css/bcsans-20221128.css new file mode 100644 index 00000000..f900c2ca --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/css/bcsans-20221128.css @@ -0,0 +1,24 @@ +@font-face { + src: url('../fonts/bcsans-regular.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-family: 'BCSans'; +} +@font-face { + src: url('../fonts/bcsans-bolditalic.woff') format('woff'); + font-weight: 700; + font-style: italic; + font-family: 'BCSans'; +} +@font-face { + src: url('../fonts/bcsans-italic.woff') format('woff'); + font-weight: 400; + font-style: italic; + font-family: 'BCSans'; +} +@font-face { + src: url('../fonts/bcsans-bold.woff') format('woff'); + font-weight: 700; + font-style: normal; + font-family: 'BCSans'; +} diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/css/styles-20221128.css b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/css/styles-20221128.css new file mode 100644 index 00000000..08207f32 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/css/styles-20221128.css @@ -0,0 +1,292 @@ +.login-pf body { + background: none; + background-color: #0f3362; + font-family: 'BCSans', 'Noto Sans', Verdana, Arial, sans-serif; + color: #036; +} + +.login-pf body .login-pf-page { + padding-bottom: 60px; +} + +.login-pf body .login-pf-page #kc-header { + margin-bottom: 0; +} + +.login-pf body .login-pf-page #kc-header #kc-header-wrapper { + font-size: 2rem; + font-weight: 700; + text-align: center; + text-transform: uppercase; + color: white; + padding: 62px 10px 20px; +} + +.login-pf body .login-pf-page .login-pf-accounts, +.login-pf body .login-pf-page .card-pf { + margin-bottom: 10px; + max-width: 450px; + padding: 10px 20px; + margin: auto; +} + +@media (max-width: 450px) { + .login-pf body .login-pf-page .login-pf-accounts, + .login-pf body .login-pf-page .card-pf { + margin: 0 0.5rem; + } +} + +.login-pf body .login-pf-page .login-pf-header { + padding-top: 20px; + margin-bottom: 0 !important; + text-align: center; + position: relative; +} + +.login-pf body .login-pf-page .login-pf-header::before { + content: url('../img/logo.svg'); + display: block; + padding-bottom: 20px; +} + +.login-pf body .login-pf-page .login-pf-header #kc-page-title { + font-size: 1rem; + font-weight: 700; + border-bottom: 1px solid #b3b1b3; + color: #036; + padding-bottom: 5px; + margin-bottom: 0; +} + +.login-pf body .login-pf-page #kc-content #kc-content-wrapper #kc-form-wrapper { + width: 100%; + margin-bottom: 20px; + border-right: none; +} + +.login-pf body .login-pf-page #kc-content #kc-content-wrapper #kc-error-message { + padding: 7px 10px 0 10px; + margin: 5px; + text-align: center; + border: 2px solid red; + border-radius: 5px; +} + +.login-pf body .login-pf-page #kc-content #kc-content-wrapper .kc-social-provider-name { + top: 0; +} + +.login-pf body .login-pf-page #kc-content #kc-content-wrapper .kc-social-link > a { + display: flex; + position: relative; +} + +.login-pf body .login-pf-page #kc-content #kc-content-wrapper .kc-social-link > a > .kc-social-title { + width: 100%; +} + +.login-pf body .login-pf-page #kc-content #kc-content-wrapper .kc-social-link > a > .kc-social-icon { + width: 28px; + position: absolute; + right: 10px; +} + +.login-pf body .login-pf-page .kc-social-links { + display: block; +} + +/* HEADER */ +.login-pf body > header { + background-color: #036; + border-bottom: 2px solid #fcba19; + padding: 0 2rem; + color: #fff; + display: flex; + height: 65px; + top: 0; + position: fixed; + width: 100%; + z-index: 100; +} + +.login-pf body > header .banner { + display: flex; + justify-content: flex-start; + align-items: center; + margin: 0 10px 0 0; +} + +.login-pf body > header .banner span { + content: url('../img/bcgovlogo.svg'); + display: block; + height: 100%; +} + +.login-pf body > header .banner h1 { + font-family: 'BCSans', 'Noto Sans', Verdana, Arial, sans-serif; + font-weight: 700; + margin: 5px 5px 0 18px; + white-space: nowrap; +} + +.login-pf body > header .other { + display: flex; + align-items: center; + flex-grow: 1; +} + +/* FOOTER */ +.login-pf body footer { + padding: 0 2rem; + position: fixed; + bottom: 0; + width: 100vw; + background-color: #036; + border-top: 2px solid #fcba19; + color: #fff; + font-family: 'BCSans', 'Noto Sans', Verdana, Arial, sans-serif; +} + +.login-pf body footer .list { + display: flex; + justify-content: center; + flex-direction: column; + text-align: center; + height: 3rem; +} + +.login-pf body footer ul { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: 0; + color: #fff; + list-style: none; + align-items: center; + height: 100%; +} + +.login-pf body footer ul li a { + font-size: 1rem; + font-weight: normal; /* 400 */ + color: #fff; + border-right: 1px solid #4b5e7e; + padding-left: 0.8rem; + padding-right: 0.8rem; +} + +/* BCGOV PRIMARY BUTTON */ +#kc-form-buttons input, +#kc-social-providers .kc-social-links a[type='button'], +.bcgov-primary { + background-color: #003366; + border: none; + border-radius: 4px; + color: white; + padding: 0.3rem 0.5rem; + text-align: center; + text-decoration: none; + display: block; + font-size: 1rem; + font-family: 'BCSans', 'Noto Sans', Verdana, Arial, sans-serif; + font-weight: 700; + letter-spacing: 1px; + cursor: pointer; +} + +#kc-form-buttons input { + padding: 0.4rem 0.5rem; +} + +#kc-form-buttons input:hover, +#kc-social-providers .kc-social-links a[type='button']:hover, +.bcgov-primary:hover { + color: white !important; + text-decoration-line: none; + border: none; + border-radius: 4px; + background-color: rgb(0, 51, 102, 0.8); +} + +#kc-form-buttons input:after, +#kc-social-providers .kc-social-links a[type='button']:after, +.bcgov-primary:after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ''; + border: none; + border-color: transparent; + border-radius: 4px; +} + +#kc-form-buttons input:focus, +#kc-social-providers .kc-social-links a[type='button']:focus, +.bcgov-primary:focus { + outline: 4px solid #3b99fc; + outline-offset: 1px; +} + +#kc-form-buttons input:active, +#kc-social-providers .kc-social-links a[type='button']:active, +.bcgov-primary:active { + opacity: 1; +} + +/* GENERAL */ +.hidden { + display: none; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +@media (max-width: 768px) { + .login-pf body > header, + .login-pf body footer { + padding: 0 0.3rem; + } + + .login-pf body .login-pf-page #kc-header #kc-header-wrapper { + font-size: 1.8rem; + } +} + +.tooltiptext { + visibility: hidden; + background-color: #333; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 5px 0; + width: 220px; + bottom: 100%; + left: 50%; + margin: 10px -110px; + /* Position the tooltip */ + position: absolute; + z-index: 1; +} + +.tooltiptext:before { + content: ''; + position: absolute; + top: 100%; + left: 50%; + margin-left: -8px; + border: 8px solid transparent; + border-top: 8px solid #333; +} + +.kc-social-icon:hover .tooltiptext { + visibility: visible; +} diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-bold.woff b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-bold.woff new file mode 100644 index 00000000..f2ecf167 Binary files /dev/null and b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-bold.woff differ diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-bolditalic.woff b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-bolditalic.woff new file mode 100644 index 00000000..9a3353c2 Binary files /dev/null and b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-bolditalic.woff differ diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-italic.woff b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-italic.woff new file mode 100644 index 00000000..fb061a3e Binary files /dev/null and b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-italic.woff differ diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-regular.woff b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-regular.woff new file mode 100644 index 00000000..07f8f0b7 Binary files /dev/null and b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/fonts/bcsans-regular.woff differ diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/img/bcgovlogo.svg b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/img/bcgovlogo.svg new file mode 100644 index 00000000..5ab211cb --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/img/bcgovlogo.svg @@ -0,0 +1 @@ +BCID_H_rgb_rev diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/img/logo.svg b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/img/logo.svg new file mode 100644 index 00000000..1e5f6586 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/img/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/js/script-20221128.js b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/js/script-20221128.js new file mode 100644 index 00000000..01423766 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/resources/js/script-20221128.js @@ -0,0 +1,77 @@ +document.addEventListener('DOMContentLoaded', function (event) { + // restart login + forceRestartLogin(); + // use gov icon for tab: + updateFavIcon(); + + const errorElem = document.getElementById('kc-error-message'); + const titleContent = errorElem ? 'Login Error:' : 'Authenticate with:'; + document.getElementById('kc-page-title').innerHTML = titleContent; + + addTooltips(); + + if (titleContent === 'Login Error:') { + if ( + document.getElementsByClassName('login-err-username')[0] && + document.getElementsByClassName('instruction-link')[0] + ) { + const pageURLQueryParams = new URLSearchParams(window.location.search); + + const pageURLQueryParamsObject = {}; + + for (const [key, value] of pageURLQueryParams) { + pageURLQueryParamsObject[key] = value; + } + + document.getElementsByClassName( + 'instruction-link', + )[0].href = `${window.location.protocol}//${window.location.host}/auth/realms/standard/login-actions/restart?client_id=${pageURLQueryParamsObject?.client_id}&tab_id=${pageURLQueryParamsObject?.tab_id}`; + + // Replace the username with the identity provider alias + const usernameRegex = /'[a-zA-Z0-9+=@&^#]+@([a-zA-Z0-9-]+)'/g; + + const loginErrorString = document.getElementsByClassName('login-err-username')[0].innerText; + + const updatedDifferentUserAuthenticatedMessage = loginErrorString.replace( + usernameRegex, + (match, capturedGroup) => { + // If a match is found, replace it with the captured group + // Otherwise, return the original match + return capturedGroup || match; + }, + ); + + document.getElementsByClassName('login-err-username')[0].innerText = updatedDifferentUserAuthenticatedMessage; + } + } +}); + +function updateFavIcon() { + var link = document.querySelector("link[rel*='icon']") || document.createElement('link'); + link.type = 'image/x-icon'; + link.rel = 'shortcut icon'; + link.href = 'https://portal.nrs.gov.bc.ca/nrs-portal-theme/images/favicon.ico'; + document.getElementsByTagName('head')[0].appendChild(link); +} + +function addTooltips() { + const tooltips = document.getElementsByClassName('tooltiptext'); + for (var x = 0; x < tooltips.length; x++) { + var elem = tooltips[x]; + var content = elem.textContent; + + if (content) { + var textNode = document.createTextNode(content); + elem.innerHTML = ''; + elem.appendChild(textNode); + } + } +} + +function forceRestartLogin() { + var restartLoginLink = document.getElementById('reset-login'); + var otpInput = document.getElementById('otp'); + if (restartLoginLink && !otpInput) { + window.location.href = restartLoginLink.href; + } +} diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/template-original.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/template-original.ftl new file mode 100644 index 00000000..904a9338 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/template-original.ftl @@ -0,0 +1,155 @@ +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false> + + + + + + + + + <#if properties.meta?has_content> + <#list properties.meta?split(' ') as meta> + + + + ${msg("loginTitle",(realm.displayName!''))} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if scripts??> + <#list scripts as script> + + + + + + +
+
+
+ ${kcSanitize(msg("loginTitleHtml",(realm.displayNameHtml!'')))?no_esc} +
+
+
+
+ <#if realm.internationalizationEnabled && locale.supported?size gt 1> +
+
+
+ ${locale.current} +
    + <#list locale.supported as l> +
  • + ${l.label} +
  • + +
+
+
+
+ + <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+

<#nested "header">

+
+
+ <#else> +

<#nested "header">

+ + <#else> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+ <#nested "show-username"> +
+ + + + +
+
+
+ <#else> + <#nested "show-username"> +
+ + + + +
+ + +
+
+
+ + <#-- App-initiated actions should not see warning messages about the need to complete the action --> + <#-- during login. --> + <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)> +
+
+ <#if message.type = 'success'> + <#if message.type = 'warning'> + <#if message.type = 'error'> + <#if message.type = 'info'> +
+ ${kcSanitize(message.summary)?no_esc} +
+ + + <#nested "form"> + + <#if auth?has_content && auth.showTryAnotherWayLink()> +
+ +
+ + + <#nested "socialProviders"> + + <#if displayInfo> +
+
+ <#nested "info"> +
+
+ +
+
+ +
+
+ + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/template.ftl b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/template.ftl new file mode 100644 index 00000000..63f44ec3 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/template.ftl @@ -0,0 +1,183 @@ +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false> + + + + + + + + + <#if properties.meta?has_content> + <#list properties.meta?split(' ') as meta> + + + + ${msg("loginTitle",(realm.displayName!''))} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if scripts??> + <#list scripts as script> + + + + + + + +<#if properties.kcShowHeader == "true"> +
+ +
 
+
+ + +
+
+
+ <#if properties.kcLoginTitleType == "client" && client?? && client.getName()?has_content> + ${kcSanitize(msg("loginTitleHtml",(client.getName()!'')))?no_esc} + <#else> + ${kcSanitize(msg("loginTitleHtml",(realm.displayNameHtml!'')))?no_esc} + +
+
+
+
+ <#if realm.internationalizationEnabled && locale.supported?size gt 1> +
+
+
+ ${locale.current} +
    + <#list locale.supported as l> +
  • + ${l.label} +
  • + +
+
+
+
+ + <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+

<#nested "header">

+
+
+ <#else> +

<#nested "header">

+ + <#else> + <#if displayRequiredFields> +
+
+ * ${msg("requiredFields")} +
+
+ <#nested "show-username"> +
+ + + + +
+
+
+ <#else> + <#nested "show-username"> +
+ + + + +
+ + +
+
+
+ + <#-- App-initiated actions should not see warning messages about the need to complete the action --> + <#-- during login. --> + <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)> +
+
+ <#if message.type = 'success'> + <#if message.type = 'warning'> + <#if message.type = 'error'> + <#if message.type = 'info'> +
+ ${kcSanitize(message.summary)?no_esc} +
+ + + <#nested "form"> + + <#if auth?has_content && auth.showTryAnotherWayLink()> +
+ +
+ + + <#nested "socialProviders"> + + <#if displayInfo> +
+
+ <#nested "info"> +
+
+ +
+
+
+
+ <#if properties.kcShowFooter == "true"> + + + + + diff --git a/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/theme.properties b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/theme.properties new file mode 100644 index 00000000..20e93493 --- /dev/null +++ b/docker/keycloak/extensions-24/themes/src/main/resources/theme/bcgov/login/theme.properties @@ -0,0 +1,9 @@ +parent=keycloak +import=common/keycloak +styles=css/login.css css/tile.css css/bcsans-20221128.css css/styles-20221128.css +scripts=js/script-20221128.js + +kcLoginTitleType=client +kcShowHeader=true +kcShowHeaderTitle=true +kcShowFooter=true diff --git a/docker/keycloak/extensions-7.6/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProvider.java b/docker/keycloak/extensions-7.6/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProvider.java index 82c99baf..fcb244cb 100755 --- a/docker/keycloak/extensions-7.6/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProvider.java +++ b/docker/keycloak/extensions-7.6/services/src/main/java/com/github/bcgov/keycloak/broker/oidc/OverrideOIDCIdentityProvider.java @@ -38,25 +38,27 @@ public Response keycloakInitiatedBrowserLogout( } String sessionId = userSession.getId(); - UriBuilder logoutUri = - UriBuilder.fromUri(getConfig().getLogoutUrl()).queryParam("state", sessionId); - String redirect = - RealmsResource.brokerUrl(uriInfo) - .path(IdentityBrokerService.class, "getEndpoint") - .path(OIDCEndpoint.class, "logoutResponse") - .build(realm.getName(), getConfig().getAlias()) - .toString(); + UriBuilder logoutUri = UriBuilder.fromUri(getConfig().getLogoutUrl()).queryParam("state", sessionId); + String redirect = RealmsResource.brokerUrl(uriInfo) + .path(IdentityBrokerService.class, "getEndpoint") + .path(OIDCEndpoint.class, "logoutResponse") + .build(realm.getName(), getConfig().getAlias()) + .toString(); if (idToken != null) { logoutUri.queryParam("id_token_hint", idToken); logoutUri.queryParam("post_logout_redirect_uri", redirect); } else { - if (!isLegacyLogoutRedirectUriSupported()) { - logger.warn("no id_token found and legacy logout redirect uri not supported: " + redirect); - return null; - } + // commented out as custom UI fields are not supported in KC22 + // if (!isLegacyLogoutRedirectUriSupported()) { + // logger.warn("no id_token found and legacy logout redirect uri not supported: + // " + redirect); + // return null; + // } + // logger.warn("no id_token found; use legacy redirect_uri query param: " + + // redirect); - logger.warn("no id_token found; use legacy redirect_uri query param: " + redirect); + // if id token is expired or not available then use redirect_uri logoutUri.queryParam("redirect_uri", redirect); } @@ -82,7 +84,9 @@ private String getIDTokenForLogout(KeycloakSession session, UserSessionModel use } } - public boolean isLegacyLogoutRedirectUriSupported() { - return Boolean.valueOf(getConfig().getConfig().get("legacyLogoutRedirectUriSupported")); - } + // commented out as custom UI fields are not supported in KC22 + // public boolean isLegacyLogoutRedirectUriSupported() { + // return + // Boolean.valueOf(getConfig().getConfig().get("legacyLogoutRedirectUriSupported")); + // } } diff --git a/docs/developer-guide.md b/docs/developer-guide.md index ad0e8f77..dadde274 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -90,3 +90,15 @@ The major workflows are: 1. `pre-commit`: runs `pre-commit` hooks to ensure the code-quality meets the standard. - For more details, see [.github/workflows](../.github/workflows) directory. + +## Create Release + +- Run the below command from the root of the project to create a new release. + +```sh +# update below +TAG=xxx +COMMIT_MESSAGE="chore: release" + +./push-tag.sh ${TAG} ${COMMIT_MESSAGE} +``` diff --git a/docs/migration-kc-24.md b/docs/migration-kc-24.md new file mode 100644 index 00000000..4408e243 --- /dev/null +++ b/docs/migration-kc-24.md @@ -0,0 +1,29 @@ +# Migration to RHBK v24 + +## Impacting Changes + +- Database migration: When keycloak connects to database, it updates the schema but if a table contains more than 300000 records then it refrains from the update and spits out the SQL query in the logs for us. We can grab the query and run it manually. +- Keycloak login page password input field has a toggle to show entered value +- By default `user profile` feature is enabled and the `unmanaged attributes` feature is disabled +- Attribute names like `some:attribute` or `some/attribute` are not allowed +- The `verify-profile` is enabled by default for new realms +- Offline sessions are loaded on demand instead of at the startup +- User attributes + - Two new features: Unmanaged attributes, User Profile + - User attributes are not available by default but can be provisioned in two ways + - Managed + - Unmanaged + - If `Unmanaged Attributes` feature is enabled then it provides `Attributes` tab for each user entry but if keeping it disabled then to add any attributes, they need to be configured under `User Profile` tab. + - `Unmanaged Attributes` have a max length of 2048 but can be extended if using attributes configured through `User Profile` tab + - The restriction of 2048 is effective only from the UI. + - Having `Unmanaged Attributes` feature disabled would just hide attributes in the UI but are still saved to database and can be mapped into token being returned to the client. + - Testing: An attribute with length 10000 caused login failure where the user was stranded at the verify profile page and no error was displayed. As a work around we can create an attribute under `User Profile` and set length `min` and `max` and same thing can be done via [terraform](https://registry.terraform.io/providers/mrparkers/keycloak/latest/docs/resources/realm_user_profile) +- Password hashing: `pbkdf2-sha512` +- Signing algorithm: `HS512` +- `iss` param in authentication response by default but can be disabled + - Example: `https://bcgov.github.io/keycloak-example-apps/#iss=https%3A%2F%2Fsso-keycloak-c6af30-test.apps.gold.devops.gov.bc.ca%2Fauth%2Frealms%2Fstandard` +- Searching only accepts `*` instead of `%` and `_` +- The client mappers tab was moved to `client dedicated scopes` (navigate to clients -> -> client scopes -> -dedicated) +- When service account is created, it automatically adds some claims like `clientId`, `clientHost`, and `clientAddress`. The new version uses `client_id` claim. +- Userinfo endpoint requires openid scope going forward. Also, errors are re-written related to this endpoint. +- The keycloak would reject if client has set `*` as a redirect URI and uses non-http schemes, however this has no impact on http schemes.