10 Commits

16 changed files with 761 additions and 651 deletions

View File

@@ -1,6 +1,6 @@
# Auth Extension for APS (Activiti App) # Auth Extension for APS (Activiti App)
This library was originally created to expand the functionality of Keycloak integration within the Alfresco Process Services (APS) application. It has expanded to support general OAuth, closing gaps that remain in the implementation provided by Alfresco. This is useless for the open source Activiti. This library was originally created to expand the functionality of Keycloak integration within the Alfresco Process Services (APS) application. It has expanded to support general OAuth, closing gaps that remain in the implementation provided by Alfresco. This is useless for the open source Activiti product.
APS delivers SSO capability and that is about it. It has a few shortcomings: APS delivers SSO capability and that is about it. It has a few shortcomings:
@@ -9,6 +9,8 @@ APS delivers SSO capability and that is about it. It has a few shortcomings:
This extension aims to resolve those issues. This extension aims to resolve those issues.
The older version of this extension was specific to Keycloak and was named `keycloak-activiti-app-ext`. See the [`develop-v1.4.x` branch](https://git.inteligr8.com/inteligr8/auth-activiti-app-ext/src/branch/develop-v1.4.x/) for details specific to it.
## Installation ## Installation
The installation is simple. Just include the JAR in the classpath of your APS application. This is best done by not chaning the `activiti-app.war` file, but instead including it within the classpath using your web container configuration. For Apache Tomcat, you would add or modify the following context file: `conf/Catalina/localhost/activiti-app.xml`. Its related contents would be: The installation is simple. Just include the JAR in the classpath of your APS application. This is best done by not chaning the `activiti-app.war` file, but instead including it within the classpath using your web container configuration. For Apache Tomcat, you would add or modify the following context file: `conf/Catalina/localhost/activiti-app.xml`. Its related contents would be:
@@ -29,13 +31,17 @@ grant codeBase "file:${catalina.base}/ext/-" {
} }
``` ```
### Dependencies
This extension requires the [`multiext-activiti-app-ext`](https://git.inteligr8.com/inteligr8/multiext-activiti-app-ext). Without it, APS will fail to startup. It is very small and requires no additional configuration.
## Support Matrix ## Support Matrix
| Auth Activiti App Extension | Activiti App | | Activiti App Extension | Activiti App |
| --------------------------- | --------------- | | --------------------------------------- | --------------- |
| v1.0 - v1.2 | v1.11.x | | `keycloak-activiti-app-ext` v1.0 - v1.2 | v1.11.x |
| v1.3 | v1.11.x - v2.x | | `keycloak-activiti-app-ext` v1.3 - v1.4 | v1.11.x - v2.x |
| v2.0+ | v24.x+ | | `auth-activiti-app-ext` v2.0+ | v24.x+ |
## Configuration ## Configuration
@@ -43,6 +49,13 @@ The library is highly configurable. You configure it with properties specified
This will only work if OAuth is being used. That would be the case if `activiti.identity-service.enabled` or `security.oauth2.authentication.enabled` is `true`. This will only work if OAuth is being used. That would be the case if `activiti.identity-service.enabled` or `security.oauth2.authentication.enabled` is `true`.
The following properties are used across the functionalities of this extension.
| Property | Default | Description |
| --------------------------------- | --------- | ----------- |
| `auth-ext.externalId` | `oauth` | This will serve as the external ID for users and as the prefix for the external ID of groups created or searched by this extension. Anything without an external ID is considered internal. So mismatched external IDs are never considered for anything by this extension. |
| `auth-ext.tenant` | | A preselected tenant for all operations in this extension. Only required if there are multiple tenants. |
### OAuth Authentication/Authorization ### OAuth Authentication/Authorization
The following properties were added to increase the configurability of the built-in OAuth capabilities of APS. The default in this extension adds the `microprofile-jwt` scope, which is key to providing groups/roles/entitlements. The following properties were added to increase the configurability of the built-in OAuth capabilities of APS. The default in this extension adds the `microprofile-jwt` scope, which is key to providing groups/roles/entitlements.
@@ -55,48 +68,48 @@ The following properties were added to increase the configurability of the built
The following properties provide the core functionality of this extension. That is role synchronization. The following properties provide the core functionality of this extension. That is role synchronization.
| Property | Default | Description | | Property | Default | Description |
| ---------------------------------------------- | --------- | ----------- | | --------------------------------------------- | ------------ | ----------- |
| `auth-ext.sync.externalId` | `oauth` | This will serve as the external ID for users and as the prefix for the external ID of groups created by this extension. | | `auth-ext.sync.user.createMissing` | `true` | If the user is authenticated, the user may be created in APS. |
| `auth-ext.tenant` | | A preselected tenant for all operations in this extension. Only required if there are multiple tenants. | | `auth-ext.sync.user.requireOidcGroup` | | This is only applicable when `createMissing` is `true`. If this is unset or the OAuth Authorization Server gives the user the specified group/role, then the user record will be created in APS. |
| `auth-ext.sync.user.createMissing` | `true` | If the user is authenticated, the user may be created in APS. | | `auth-ext.sync.user.clearNewUserGroups` | `true` | This is only applicable when `createMissing` is `true`. All default APS groups will be deleted from the new user record. |
| `auth-ext.sync.user.requireGroup` | | This is only applicable when `createMissing` is `true`. If this is unset or the OAuth Authorization Server gives the user the specified group/role, then the user record will be created in APS. | | `auth-ext.sync.group.createMissing` | `true` | If a filtered and translated OIDC group has no corresponding APS group, a group will be created in APS. See `auth-ext.sync.group.capabilities.patterns` for whether that group will be an APS Organization or APS Capability. |
| `auth-ext.sync.user.clearNewUserGroups` | `true` | This is only applicable when `createMissing` is `true`. All default APS groups will be deleted from the new user record. | | `auth-ext.sync.group.additions` | `true` | If the user isn't in an APS group but OAuth claims the OIDC group, then add them to it. |
| `auth-ext.sync.group.createMissing` | `true` | If a filtered and translated OIDC group has no corresponding APS group, a group will be created in APS. See `auth-ext.sync.group.capabilities.patterns` for whether that group will be an APS Organization or APS Capability. | | `auth-ext.sync.group.removals` | `true` | If the user is in APS group but OAuth claims no OIDC group, then remove them from it. |
| `auth-ext.sync.group.additions` | `true` | If the user isn't in an APS group but OAuth claims the OIDC group, then add them to it. | | `auth-ext.sync.group.internal` | `false` | When considering groups for creation or user membership, include internal groups. Internal groups are ones without an `externalId`. |
| `auth-ext.sync.group.removals` | `true` | If the user is in APS group but OAuth claims no OIDC group, then remove them from it. | | `auth-ext.sync.group.internal.externalize` | `false` | This is only applicable when `internal` is `true`. If an internal group is encountered during the operation of this extension, make it external with the current `externalId`. |
| `auth-ext.sync.group.internal` | `false` | When considering groups for creation or user membership, include internal groups. Internal groups are ones without an `externalId`. | | `auth-ext.sync.group.tenantize` | `false` | If a group without a tenant is encountered during the operation of this extension, make it part of the selected tenant. |
| `auth-ext.sync.group.internal.externalize` | `false` | This is only applicable when `internal` is `true`. If an internal group is encountered during the operation of this extension, make it external with the current `externalId`. | | `auth-ext.sync.group.include.patterns` | | A comma delimited set of regular expression patterns on what OIDC groups to include. This is processed before `translate` processing. A blank value matches everything. If anything is specified, then only matches could possibly be included. Any matches of the `exclude` property patterns always override though. |
| `auth-ext.sync.group.tenantize` | `false` | If a group without a tenant is encountered during the operation of this extension, make it part of the selected tenant. | | `auth-ext.sync.group.exclude.patterns` | | A comma delimited set of regular expression patterns on what OIDC groups to exclude. This is processed before `translate` processing. A blank value matches nothing (includes all that pass the `include` constraint). If anything is specified and `include` is empty, then all non-matches are included. If both are specified, `exclude` matches override `include` matches. |
| `auth-ext.sync.group.translate.patterns` | | A comma delimited set of regular expression patterns for the translation (reformatting) of authorities. | | `auth-ext.sync.group.translate.patterns` | | A comma delimited set of regular expression patterns for the translation (reformatting) of OIDC groups to APS groups. This list corresponds to the `replacements` property and must have the same number of commas. |
| `auth-ext.sync.group.translate.replacements` | | A comma delimited set of regular expression replacement strings for the translation (reformatting) of authorities. | | `auth-ext.sync.group.translate.replacements` | | A comma delimited set of regular expression replacement strings for the translation (reformatting) of OIDC groups to APS groups. This list corresponds to the `patterns` property and must have the same number of commas. |
| `auth-ext.sync.group.include.patterns` | | A comma delimited set of regular expression patterns on what authorities to include. This is processed before `translate` processing. A blank value includes everything. If anything is specified, then only matches could possibly be included; but could still be excluded explicitly. | | `auth-ext.sync.group.capability.patterns` | `Superusers` | A comma delimited set of regular expression patterns on what translated OIDC groups to associate with APS Capability Groups instead of APS Organization Groups (default). This is processed after `translate` processing. |
| `auth-ext.sync.group.exclude.patterns` | | A comma delimited set of regular expression patterns on what authorities to exclude. This is processed before `translate` processing. A blank value excludes nothing. If anything is specified and `include` is empty, then only matches will be excluded. If both are specified, `exclude` overrules `include` matches. |
| `auth-ext.sync.group.capabilities.patterns` | `Superusers` | A comma delimited set of regular expression patterns on what authorities to associate with APS Capabilities instead of APS Organizations (default). |
### Authentication Data Fixers ### Authentication Data Fixers
#### Administrator Password Fixer #### Administrator Password Fixer
| Property | Default | Description | This fixer does not execute unless the `auth-ext.reset.admin.password` property is explicitly set. It is meant to be set for a single run to fix scenarios when a non-OAuth administrator cannot access the system. This is useful when your OAuth configuration isn't working and you need to reset your non-OAuth administrator's password.
| ----------------------------------------- | ------------------------ | ----------- |
| `auth-ext.reset.admin.username` | `admin@app.activiti.com` | | Property | Default |
| `auth-ext.reset.admin.password` | | If set, the user's password will be set to this value on startup; otherwise this fixer is skipped. | | --------------------------------- | ------------------------ |
| `auth-ext.reset.admin.username` | `admin@app.activiti.com` |
| `auth-ext.reset.admin.password` | |
#### Administrator Members Fixer #### Administrator Members Fixer
| Property | Default | Description | This fixer does not execute unless the `auth-ext.default.admin-users` property is explicitly set. It is meant to be set for a single run to add both non-OAuth and OAuth users to an administrator group. This is useful when OAuth is misconfigured and all administrators lose their administrator rights.
| ----------------------------------------- | ------------------------ | ----------- |
| `auth-ext.default.admins.users` | | A comma delimited list of user emails; fixer is skipped if empty. | | Property | Default | Description |
| `auth-ext.group.admins.name` | `Superusers` | The APS Group (Capability or Organization) to add the specified users to. | | ------------------------------------ | ------------ | ----------- |
| `auth-ext.group.admins.externalId` | | If specified, this APS Group will be considered before the specified `name` field. | | `auth-ext.default.admins.users` | | A comma delimited list of user emails. |
| `auth-ext.group.admins.name` | `Superusers` | The APS Group (Capability or Organization) for the specified users to be members. |
#### Administrator Group Fixer #### Administrator Group Fixer
This fixer does not execute unless the `auth-ext.group.admins.validate` property is set to `true`. It is meant to be set for a single run to correct the specified group for administration access. This is useful if you accidentally delete the administrative APS Capability Group, typically called `Administrator` or `Superusers`.
| Property | Default | Description | | Property | Default | Description |
| ----------------------------------------- | ------------------------ | ----------- | | ----------------------------------------- | ------------------------ | ----------- |
| `auth-ext.group.admins.validate` | `false` | If `true`, the specified APS Group will be granted all capabilities. | | `auth-ext.group.admins.validate` | `false` |
| `auth-ext.group.admins.name` | `Superusers` | The APS Group (Capability or Organization) to add the specified users to. | | `auth-ext.group.admins.name` | `Superusers` | The APS Capability Group of which to grant all capabilities. |
| `auth-ext.group.admins.externalId` | | If specified, this APS Group will be considered before the specified `name` field. |

View File

@@ -1,178 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.inteligr8.activiti</groupId>
<artifactId>keycloak-activiti-app-ext</artifactId>
<name>Keycloak Authentication &amp; Authorization for APS</name>
<version>1.4-SNAPSHOT</version>
<description>An Alfresco Process Service App extension providing improved Keycloak/AIS support.</description>
<url>https://bitbucket.org/inteligr8/keycloak-activiti-app-ext</url>
<developers>
<developer>
<id>brian.long</id>
<name>Brian Long</name>
<email>brian@inteligr8.com</email>
<url>https://twitter.com/brianmlong</url>
</developer>
</developers>
<licenses>
<license>
<name>GNU GENERAL PUBLIC LICENSE, Version 3, 29 June 2007</name>
<url>https://www.gnu.org/licenses/lgpl-3.0.txt</url>
</license>
</licenses>
<scm>
<connection>scm:git:https://bitbucket.org/inteligr8/keycloak-activiti-app-ext.git</connection>
<developerConnection>scm:git:git@bitbucket.org:inteligr8/keycloak-activiti-app-ext.git</developerConnection>
<url>https://bitbucket.org/inteligr8/keycloak-activiti-app-ext</url>
</scm>
<organization>
<name>Inteligr8</name>
<url>https://www.inteligr8.com</url>
</organization>
<build>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>shade-jar</id>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<shadedArtifactAttached>true</shadedArtifactAttached>
<relocations>
<relocation>
<pattern />
<shadedPattern>shaded.keycloak.</shadedPattern>
<excludes>
<exclude>com.activiti.conf.*</exclude>
<exclude>com.activiti.extension.conf.*</exclude>
<exclude>com.inteligr8.activiti.**</exclude>
<exclude>META-INF/**/*</exclude>
</excludes>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>ossrh-release</id>
<build>
<plugins>
<plugin>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<id>source</id>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-javadoc-plugin</artifactId>
<executions>
<execution>
<id>javadoc</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<show>public</show>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-gpg-plugin</artifactId>
<executions>
<execution>
<id>sign</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
<version>1.7.0</version>
<executions>
<execution>
<id>ossrh-deploy</id>
<phase>deploy</phase>
<goals>
<goal>deploy</goal>
</goals>
</execution>
</executions>
<configuration>
<serverId>ossrh</serverId>
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
<autoReleaseAfterClose>true</autoReleaseAfterClose>
</configuration>
</plugin>
</plugins>
</build>
<properties>
<maven.deploy.skip>true</maven.deploy.skip>
</properties>
</profile>
</profiles>
<repositories>
<repository>
<id>alfresco-private</id>
<url>https://artifacts.alfresco.com/nexus/content/groups/private</url>
</repository>
<repository>
<id>activiti-releases</id>
<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
<version>6.3.2</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.activiti</groupId>
<artifactId>activiti-app</artifactId>
<version>24.3.0</version>
<classifier>classes</classifier>
<scope>provided</scope>
<exclusions>
<exclusion>
<artifactId>aspose-transformation</artifactId>
<groupId>com.activiti</groupId>
</exclusion>
<exclusion>
<artifactId>aoservices</artifactId>
<groupId>org.alfresco.officeservices</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<maven.compiler.source>17</maven.compiler.source>
<keycloak.version>23.0.7</keycloak.version>
<maven.compiler.target>17</maven.compiler.target>
<slf4j.version>1.7.36</slf4j.version>
<spring-security-oauth2.version>6.3.2</spring-security-oauth2.version>
<aps.version>24.3.0</aps.version>
</properties>
</project>

595
pom.xml
View File

@@ -1,294 +1,301 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>com.inteligr8.activiti</groupId>
<artifactId>auth-activiti-app-ext</artifactId> <groupId>com.inteligr8.activiti</groupId>
<version>2.0.0</version> <artifactId>auth-activiti-app-ext</artifactId>
<version>2.1-SNAPSHOT</version>
<name>Authentication &amp; Authorization for APS</name>
<description>An Alfresco Process Service App extension providing improved authentication and authorization support.</description> <name>Authentication &amp; Authorization for APS</name>
<url>https://bitbucket.org/inteligr8/auth-activiti-app-ext</url> <description>An Alfresco Process Service App extension providing improved authentication and authorization support.</description>
<url>https://git.inteligr8.com/inteligr8/auth-activiti-app-ext</url>
<licenses>
<license> <licenses>
<name>GNU GENERAL PUBLIC LICENSE, Version 3, 29 June 2007</name> <license>
<url>https://www.gnu.org/licenses/lgpl-3.0.txt</url> <name>GNU GENERAL PUBLIC LICENSE, Version 3, 29 June 2007</name>
</license> <url>https://www.gnu.org/licenses/lgpl-3.0.txt</url>
</licenses> </license>
</licenses>
<scm>
<connection>scm:git:https://bitbucket.org/inteligr8/auth-activiti-app-ext.git</connection> <scm>
<developerConnection>scm:git:git@bitbucket.org:inteligr8/auth-activiti-app-ext.git</developerConnection> <connection>scm:git:https://git.inteligr8.com/inteligr8/auth-activiti-app-ext.git</connection>
<url>https://bitbucket.org/inteligr8/auth-activiti-app-ext</url> <developerConnection>scm:git:git@git.inteligr8.com:inteligr8/auth-activiti-app-ext.git</developerConnection>
</scm> <url>https://git.inteligr8.com/inteligr8/auth-activiti-app-ext</url>
<organization> </scm>
<name>Inteligr8</name> <organization>
<url>https://www.inteligr8.com</url> <name>Inteligr8</name>
</organization> <url>https://www.inteligr8.com</url>
<developers> </organization>
<developer> <developers>
<id>brian.long</id> <developer>
<name>Brian Long</name> <id>brian.long</id>
<email>brian@inteligr8.com</email> <name>Brian Long</name>
<url>https://twitter.com/brianmlong</url> <email>brian@inteligr8.com</email>
</developer> <url>https://twitter.com/brianmlong</url>
</developers> </developer>
</developers>
<properties>
<maven.compiler.source>17</maven.compiler.source> <properties>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.release>17</maven.compiler.release> <maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>17</maven.compiler.release>
<aps.version>25.1.1</aps.version>
<aps.version>25.1.1</aps.version>
<!-- for RAD -->
<tomcat-rad.version>10-2.2</tomcat-rad.version> <!-- for RAD -->
<aps.tomcat.opts.base>-Dspring.main.allow-circular-references=true \ <tomcat-rad.version>10-2.2</tomcat-rad.version>
-Dhibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \ <aps.hotswap.enabled>false</aps.hotswap.enabled>
-Dauth-ext.oauth.enabled=true \ <aps.tomcat.opts.base>-Dspring.main.allow-circular-references=true \
-Dauth-ext.external.id=keycloak \ -Dhibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \
-Dauth-ext.sync.group.translate.patterns=aps-admin \ -Dauth-ext.external.id=keycloak \
-Dauth-ext.sync.group.translate.replacements=Superusers \ -Dauth-ext.sync.group.translate.patterns=aps-admin \
-Dauth-ext.group.admins.validate=true</aps.tomcat.opts.base> -Dauth-ext.sync.group.translate.replacements=Superusers \
<aps.timeout>120000</aps.timeout> -Dauth-ext.group.admins.validate=true</aps.tomcat.opts.base>
<keycloak.realm>my-app</keycloak.realm> <aps.timeout>120000</aps.timeout>
<oauth.client.id>aps-app-public</oauth.client.id> <keycloak.realm>my-app</keycloak.realm>
<oauth.client.secret></oauth.client.secret> <oauth.client.id>aps-app-public</oauth.client.id>
</properties> <oauth.client.secret></oauth.client.secret>
</properties>
<dependencies>
<!-- Needed for Activiti App Identity Service inheritance/override --> <dependencies>
<!-- includes activiti-app-logic for API --> <!-- Needed for Activiti App Identity Service inheritance/override -->
<dependency> <!-- includes activiti-app-logic for API -->
<groupId>com.activiti</groupId> <dependency>
<artifactId>activiti-app</artifactId> <groupId>com.activiti</groupId>
<version>${aps.version}</version> <artifactId>activiti-app</artifactId>
<classifier>classes</classifier> <version>${aps.version}</version>
<scope>provided</scope> <classifier>classes</classifier>
<exclusions> <scope>provided</scope>
<!-- not necessary to download for building --> <exclusions>
<exclusion> <!-- not necessary to download for building -->
<groupId>com.activiti</groupId> <exclusion>
<artifactId>aspose-transformation</artifactId> <groupId>com.activiti</groupId>
</exclusion> <artifactId>aspose-transformation</artifactId>
<exclusion> </exclusion>
<groupId>org.alfresco.officeservices</groupId> <exclusion>
<artifactId>aoservices</artifactId> <groupId>org.alfresco.officeservices</groupId>
</exclusion> <artifactId>aoservices</artifactId>
<!-- very old and overrides real spring version --> </exclusion>
<exclusion> <!-- very old and overrides real spring version -->
<groupId>com.ryantenney.metrics</groupId> <exclusion>
<artifactId>metrics-spring</artifactId> <groupId>com.ryantenney.metrics</groupId>
</exclusion> <artifactId>metrics-spring</artifactId>
<exclusion> </exclusion>
<groupId>org.springframework.security.oauth</groupId> <exclusion>
<artifactId>spring-security-oauth2</artifactId> <groupId>org.springframework.security.oauth</groupId>
</exclusion> <artifactId>spring-security-oauth2</artifactId>
<exclusion> </exclusion>
<groupId>org.springframework.security.oauth.boot</groupId> <exclusion>
<artifactId>spring-security-oauth2-autoconfigure</artifactId> <groupId>org.springframework.security.oauth.boot</groupId>
</exclusion> <artifactId>spring-security-oauth2-autoconfigure</artifactId>
</exclusions> </exclusion>
</dependency> </exclusions>
</dependencies> </dependency>
<dependency>
<build> <groupId>com.inteligr8.activiti</groupId>
<plugins> <artifactId>multiext-activiti-app-ext</artifactId>
<plugin> <version>1.0.0</version>
<groupId>io.repaint.maven</groupId> <scope>runtime</scope>
<artifactId>tiles-maven-plugin</artifactId> </dependency>
<version>2.40</version> </dependencies>
<extensions>true</extensions>
<configuration> <build>
<tiles> <plugins>
<!-- Documentation: https://bitbucket.org/inteligr8/ootbee-beedk/src/stable/beedk-aps-ext-rad-tile --> <plugin>
<!-- <groupId>io.repaint.maven</groupId>
<tile>com.inteligr8.ootbee:beedk-aps-ext-rad-tile:[1.1.0,2.0.0)</tile> <artifactId>tiles-maven-plugin</artifactId>
--> <version>2.40</version>
<tile>com.inteligr8.ootbee:beedk-aps-ext-rad-tile:1.1-SNAPSHOT</tile> <extensions>true</extensions>
</tiles> <configuration>
</configuration> <tiles>
</plugin> <!-- Documentation: https://bitbucket.org/inteligr8/ootbee-beedk/src/stable/beedk-aps-ext-rad-tile -->
</plugins> <!--
</build> <tile>com.inteligr8.ootbee:beedk-aps-ext-rad-tile:[1.1.0,2.0.0)</tile>
-->
<profiles> <tile>com.inteligr8.ootbee:beedk-aps-ext-rad-tile:1.1-SNAPSHOT</tile>
<profile> </tiles>
<id>activiti-oauth-confidential</id> </configuration>
<activation> </plugin>
<property> </plugins>
<name>secret</name> </build>
</property>
</activation> <profiles>
<properties> <profile>
<oauth.client.id>aps-app-confidential</oauth.client.id> <id>activiti-oauth-confidential</id>
<oauth.client.secret>a-secret</oauth.client.secret> <activation>
</properties> <property>
</profile> <name>secret</name>
<profile> </property>
<id>activiti-oauth-legacy</id> </activation>
<activation> <properties>
<property> <oauth.client.id>aps-app-confidential</oauth.client.id>
<name>rad</name> <oauth.client.secret>a-secret</oauth.client.secret>
<value>!spring</value> </properties>
</property> </profile>
</activation> <profile>
<properties> <id>activiti-oauth-legacy</id>
<aps.tomcat.opts>${aps.tomcat.opts.base} \ <activation>
-Dactiviti.identity-service.enabled=true \ <property>
-Dactiviti.identity-service.realm=${keycloak.realm} \ <name>rad</name>
-Dactiviti.identity-service.auth-server-url=http://host.docker.internal:${keycloak.server.port} \ <value>!spring</value>
-Dactiviti.identity-service.resource=${oauth.client.id} \ </property>
-Dactiviti.identity-service.credentials.secret=${oauth.client.secret} \ </activation>
-Dactiviti.use-browser-based-logout=true \ <properties>
-Dalfresco.content.sso.redirect_uri=http://loalhost:8080/activiti-app/app/rest/integration/sso/confirm-auth-request</aps.tomcat.opts> <aps.tomcat.opts>${aps.tomcat.opts.base} \
</properties> -Dactiviti.identity-service.enabled=true \
</profile> -Dactiviti.identity-service.realm=${keycloak.realm} \
<profile> -Dactiviti.identity-service.auth-server-url=http://host.docker.internal:${keycloak.server.port} \
<id>activiti-oauth-spring</id> -Dactiviti.identity-service.resource=${oauth.client.id} \
<activation> -Dactiviti.identity-service.credentials.secret=${oauth.client.secret} \
<property> -Dactiviti.use-browser-based-logout=true \
<name>rad</name> -Dalfresco.content.sso.redirect_uri=http://loalhost:8080/activiti-app/app/rest/integration/sso/confirm-auth-request</aps.tomcat.opts>
<value>spring</value> </properties>
</property> </profile>
</activation> <profile>
<properties> <id>activiti-oauth-spring</id>
<aps.tomcat.opts>${aps.tomcat.opts.base} \ <activation>
-Dsecurity.oauth2.authentication.enabled=true \ <property>
-Dsecurity.oauth2.client.registration.my-app.client-id=${oauth.client.id} \ <name>rad</name>
-Dsecurity.oauth2.client.registration.my-app.client-secret=${oauth.client.secret} \ <value>spring</value>
-Dsecurity.oauth2.client.registration.my-app.provider=aps-app \ </property>
-Dsecurity.oauth2.client.provider.aps-app.issuer_uri=http://host.docker.internal:${keycloak.server.port}/realms/${keycloak.realm}</aps.tomcat.opts> </activation>
</properties> <properties>
</profile> <aps.tomcat.opts>${aps.tomcat.opts.base} \
<profile> -Dsecurity.oauth2.authentication.enabled=true \
<id>rad-keycloak</id> -Dsecurity.oauth2.client.registration.my-app.client-id=${oauth.client.id} \
<activation> -Dsecurity.oauth2.client.registration.my-app.client-secret=${oauth.client.secret} \
<property> -Dsecurity.oauth2.client.registration.my-app.provider=aps-app \
<name>rad</name> -Dsecurity.oauth2.client.provider.aps-app.issuer_uri=http://host.docker.internal:${keycloak.server.port}/realms/${keycloak.realm}</aps.tomcat.opts>
</property> </properties>
</activation> </profile>
<properties> <profile>
<!-- Due to SSL restricitons in previous versions, testing against keyclaok is near impossible. --> <id>rad-keycloak</id>
<!-- This module should still work against nearly all versions of Keycloak that support the OIDC standards --> <activation>
<keycloak.server.version>26.2</keycloak.server.version> <property>
<keycloak.server.port>8081</keycloak.server.port> <name>rad</name>
</properties> </property>
<build> </activation>
<plugins> <properties>
<plugin> <!-- Due to SSL restricitons in previous versions, testing against keyclaok is near impossible. -->
<groupId>io.fabric8</groupId> <!-- This module should still work against nearly all versions of Keycloak that support the OIDC standards -->
<artifactId>docker-maven-plugin</artifactId> <keycloak.server.version>26.2</keycloak.server.version>
<version>0.46.0</version> <keycloak.server.port>8081</keycloak.server.port>
<executions> </properties>
<execution> <build>
<id>run-keycloak</id> <plugins>
<phase>test-compile</phase> <plugin>
<goals><goal>start</goal></goals> <groupId>io.fabric8</groupId>
<configuration> <artifactId>docker-maven-plugin</artifactId>
<images> <version>0.46.0</version>
<image> <executions>
<name>keycloak/keycloak:${keycloak.server.version}</name> <execution>
<alias>keycloak</alias> <id>run-keycloak</id>
<run> <phase>test-compile</phase>
<cmd>start-dev --import-realm</cmd> <goals><goal>start</goal></goals>
<env> <configuration>
<KC_BOOTSTRAP_ADMIN_USERNAME>admin</KC_BOOTSTRAP_ADMIN_USERNAME> <images>
<KC_BOOTSTRAP_ADMIN_PASSWORD>admin</KC_BOOTSTRAP_ADMIN_PASSWORD> <image>
</env> <name>keycloak/keycloak:${keycloak.server.version}</name>
<ports> <alias>keycloak</alias>
<port>${keycloak.server.port}:8080</port> <run>
</ports> <cmd>start-dev --import-realm</cmd>
<network> <env>
<mode>custom</mode> <KC_BOOTSTRAP_ADMIN_USERNAME>admin</KC_BOOTSTRAP_ADMIN_USERNAME>
<name>${project.artifactId}</name> <KC_BOOTSTRAP_ADMIN_PASSWORD>admin</KC_BOOTSTRAP_ADMIN_PASSWORD>
</network> </env>
<extraHosts> <ports>
<host>host.docker.internal:host-gateway</host> <port>${keycloak.server.port}:8080</port>
</extraHosts> </ports>
<volumes> <network>
<bind> <mode>custom</mode>
<volume>${project.basedir}/src/test/resources/keycloak-import:/opt/keycloak/data/import:ro</volume> <name>${project.artifactId}</name>
</bind> </network>
</volumes> <extraHosts>
</run> <host>host.docker.internal:host-gateway</host>
</image> </extraHosts>
</images> <volumes>
</configuration> <bind>
</execution> <volume>${project.basedir}/src/test/resources/keycloak-import:/opt/keycloak/data/import:ro</volume>
</executions> </bind>
</plugin> </volumes>
</plugins> </run>
</build> </image>
</profile> </images>
<profile> </configuration>
<id>ossrh-release</id> </execution>
<properties> </executions>
<maven.deploy.skip>true</maven.deploy.skip> </plugin>
</properties> </plugins>
<build> </build>
<plugins> </profile>
<plugin> <profile>
<artifactId>maven-source-plugin</artifactId> <id>ossrh-release</id>
<executions> <properties>
<execution> <maven.deploy.skip>true</maven.deploy.skip>
<id>source</id> </properties>
<phase>package</phase> <build>
<goals><goal>jar-no-fork</goal></goals> <plugins>
</execution> <plugin>
</executions> <artifactId>maven-source-plugin</artifactId>
</plugin> <executions>
<plugin> <execution>
<artifactId>maven-javadoc-plugin</artifactId> <id>source</id>
<executions> <phase>package</phase>
<execution> <goals><goal>jar-no-fork</goal></goals>
<id>javadoc</id> </execution>
<phase>package</phase> </executions>
<goals><goal>jar</goal></goals> </plugin>
<configuration> <plugin>
<show>public</show> <artifactId>maven-javadoc-plugin</artifactId>
</configuration> <executions>
</execution> <execution>
</executions> <id>javadoc</id>
</plugin> <phase>package</phase>
<plugin> <goals><goal>jar</goal></goals>
<artifactId>maven-gpg-plugin</artifactId> <configuration>
<executions> <show>public</show>
<execution> </configuration>
<id>sign</id> </execution>
<phase>verify</phase> </executions>
<goals><goal>sign</goal></goals> </plugin>
</execution> <plugin>
</executions> <artifactId>maven-gpg-plugin</artifactId>
</plugin> <executions>
<plugin> <execution>
<groupId>org.sonatype.plugins</groupId> <id>sign</id>
<artifactId>nexus-staging-maven-plugin</artifactId> <phase>verify</phase>
<version>1.7.0</version> <goals><goal>sign</goal></goals>
<configuration> </execution>
<serverId>ossrh</serverId> </executions>
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl> </plugin>
<autoReleaseAfterClose>true</autoReleaseAfterClose> <plugin>
</configuration> <groupId>org.sonatype.plugins</groupId>
<executions> <artifactId>nexus-staging-maven-plugin</artifactId>
<execution> <version>1.7.0</version>
<id>ossrh-deploy</id> <configuration>
<phase>deploy</phase> <serverId>ossrh</serverId>
<goals><goal>deploy</goal></goals> <nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
</execution> <autoReleaseAfterClose>true</autoReleaseAfterClose>
</executions> </configuration>
</plugin> <executions>
</plugins> <execution>
</build> <id>ossrh-deploy</id>
</profile> <phase>deploy</phase>
</profiles> <goals><goal>deploy</goal></goals>
</execution>
<repositories> </executions>
<repository> </plugin>
<id>activiti-releases</id> </plugins>
<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url> </build>
</repository> </profile>
</repositories> </profiles>
</project> <repositories>
<repository>
<id>activiti-releases</id>
<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url>
</repository>
</repositories>
</project>

View File

@@ -1,35 +1,35 @@
/* /*
* This program is free software: you can redistribute it and/or modify it * This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by * under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at your * the Free Software Foundation, either version 3 of the License, or (at your
* option) any later version. * option) any later version.
* *
* This program is distributed in the hope that it will be useful, but WITHOUT * This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details. * more details.
* *
* You should have received a copy of the GNU General Public License along * You should have received a copy of the GNU General Public License along
* with this program. If not, see <https://www.gnu.org/licenses/>. * with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.activiti.extension.conf; package com.activiti.extension.conf;
import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FullyQualifiedAnnotationBeanNameGenerator; import org.springframework.context.annotation.FullyQualifiedAnnotationBeanNameGenerator;
/** /**
* A means for injecting packages to scan for the Spring context. * A means for injecting packages to scan for the Spring context.
* *
* @author brian@inteligr8.com * @author brian@inteligr8.com
*/ */
@Configuration @Configuration
@ComponentScan( @ComponentScan(
basePackages = { basePackages = {
"com.inteligr8.activiti.auth" "com.inteligr8.activiti.auth"
}, },
nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class
) )
public class AuthExtSpringComponentScanner { public class AuthExtSpringComponentScanner {
} }

View File

@@ -64,7 +64,7 @@ public class ActivitiAppAdministratorGroupFixer implements DataFixer {
@Value("${auth-ext.group.admins.name:Superusers}") @Value("${auth-ext.group.admins.name:Superusers}")
private String adminGroupName; private String adminGroupName;
@Value("${auth-ext.sync.externalId:oauth}") @Value("${auth-ext.externalId:oauth}")
protected String externalIdmSource; protected String externalIdmSource;
@Value("${auth-ext.group.admins.validate:false}") @Value("${auth-ext.group.admins.validate:false}")

View File

@@ -58,7 +58,7 @@ public class ActivitiAppAdministratorMembersFixer implements DataFixer {
@Value("${auth-ext.group.admins.name:Superusers}") @Value("${auth-ext.group.admins.name:Superusers}")
private String adminGroupName; private String adminGroupName;
@Value("${auth-ext.group.admins.externalId:#{null}}") @Value("${auth-ext.externalId:oauth}")
private String adminGroupExternalId; private String adminGroupExternalId;
@Override @Override

View File

@@ -1,32 +0,0 @@
package com.inteligr8.activiti.auth;
import java.util.Map;
import java.util.Map.Entry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import com.activiti.api.boot.BootstrapConfigurer;
@Component("bootstrap.proxy")
@Primary
public class Bootstrapper implements BootstrapConfigurer {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public void applicationContextInitialized(ApplicationContext applicationContext) {
Map<String, BootstrapConfigurer> bootstraps = applicationContext.getBeansOfType(BootstrapConfigurer.class);
bootstraps.remove("bootstrap.proxy");
this.logger.debug("Executing {} bootstrap configurers", bootstraps.size());
for (Entry<String, BootstrapConfigurer> bootstrap : bootstraps.entrySet()) {
this.logger.trace("Executing bootstrap configurer: {}: {}", bootstrap.getKey(), bootstrap.getValue().getClass());
bootstrap.getValue().applicationContextInitialized(applicationContext);
}
}
}

View File

@@ -1,5 +1,8 @@
package com.inteligr8.activiti.auth.oauth; package com.inteligr8.activiti.auth.oauth;
import static org.springframework.security.config.Customizer.withDefaults;
import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -10,11 +13,22 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import com.activiti.domain.idm.Capabilities;
import com.activiti.security.ActivitiAppRequestHeaderService;
import com.activiti.security.ActivitiRestAuthorizationService;
import com.activiti.security.ProtectedPaths;
import com.activiti.security.identity.service.config.IdentityServiceEnabledCondition; import com.activiti.security.identity.service.config.IdentityServiceEnabledCondition;
import com.inteligr8.activiti.auth.service.JwtAuthenticationProvider;
import com.nimbusds.oauth2.sdk.ParseException; import com.nimbusds.oauth2.sdk.ParseException;
/** /**
@@ -33,6 +47,15 @@ public class IdentityServiceConfigurationOverride {
@Autowired @Autowired
private ApplicationContext appContext; private ApplicationContext appContext;
@Autowired
private JwtAuthenticationProvider jwtAuthenticationProvider;
@Autowired
private ActivitiAppRequestHeaderService appRequestHeaderService;
@Autowired
private ActivitiRestAuthorizationService restAuthorizationService;
@Bean("inteligr8.clientRegistrationRepository") @Bean("inteligr8.clientRegistrationRepository")
@Primary @Primary
public ClientRegistrationRepository clientRegistrationRepository() { public ClientRegistrationRepository clientRegistrationRepository() {
@@ -51,7 +74,7 @@ public class IdentityServiceConfigurationOverride {
@Bean(OVERRIDE_CLIENT_REGISTRATION_BEANNAME) @Bean(OVERRIDE_CLIENT_REGISTRATION_BEANNAME)
@Primary @Primary
public ClientRegistration clientRegistration1() throws ParseException, InterruptedException { public ClientRegistration clientRegistration() throws ParseException, InterruptedException {
this.logger.trace("clientRegistration()"); this.logger.trace("clientRegistration()");
ClientRegistration clientRegistration = this.appContext.getBean(OOTB_CLIENT_REGISTRATION_BEANNAME, ClientRegistration.class); ClientRegistration clientRegistration = this.appContext.getBean(OOTB_CLIENT_REGISTRATION_BEANNAME, ClientRegistration.class);
@@ -62,4 +85,62 @@ public class IdentityServiceConfigurationOverride {
.build(); .build();
} }
/**
* Slightly higher priority than the one provided OOTB. This allows for
* the bean injection of the `JwtAuthenticationConverter`.
*
* This is basically a copy of what is provided OOTB, but:
*
* - The ability to configure the `JwtAuthenticationConverter`.
* - Allow non-UI access to `/app/rest/*`
*
* @see com.activiti.security.identity.service.config.IdentityServiceConfigurationApi#identityServiceApiWebSecurity
*/
@Bean("inteligr8.identityServiceApiWebSecurity")
@Order(-5)
public SecurityFilterChain identityServiceApiWebSecurity(HttpSecurity http) throws Exception {
http
.securityMatchers(matchers -> {
matchers.requestMatchers(
// same as OOTB
antMatcher(ProtectedPaths.API_URL_PATH + "/**"),
// want to also allow non-UI access to the the protected API
// we do this for anything with an `Authorization` header, as the UI uses session-based authorization
new AndRequestMatcher(new RequestHeaderRequestMatcher("Authorization"), antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/**"))
);
})
.csrf(csrf -> {
csrf.disable();
})
.cors(withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Stores no Session for API calls
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwtConfigurer -> {
// here is where we are injecting a Spring extendible `JwtAuthenticationConverter`.
jwtConfigurer.jwtAuthenticationConverter(this.jwtAuthenticationProvider.create());
})
)
.authorizeHttpRequests(request ->
request
// same as OOTB
.requestMatchers(antMatcher(ProtectedPaths.API_URL_PATH + "/enterprise/**"))
.access(this.appRequestHeaderService)
.requestMatchers(antMatcher(ProtectedPaths.API_URL_PATH + "/**"))
.access(this.restAuthorizationService)
// borrowed from OOTB /app/rest security
.requestMatchers(antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/reporting/**"))
.hasAuthority(Capabilities.ACCESS_REPORTS)
.requestMatchers(
antMatcher(ProtectedPaths.API_URL_PATH + "/**"),
antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/**")
)
.authenticated()
);
return http.build();
}
} }

View File

@@ -30,7 +30,10 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.data.util.Pair; import org.springframework.data.util.Pair;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.activiti.domain.idm.Group; import com.activiti.domain.idm.Group;
@@ -56,7 +59,7 @@ public class GroupSyncService {
@Autowired @Autowired
private TenantFinderService tenantFinderService; private TenantFinderService tenantFinderService;
@Value("${auth-ext.sync.externalId:oauth}") @Value("${auth-ext.externalId:oauth}")
protected String externalIdmSource; protected String externalIdmSource;
@Value("${auth-ext.sync.group.createMissing:true}") @Value("${auth-ext.sync.group.createMissing:true}")
@@ -129,28 +132,36 @@ public class GroupSyncService {
} }
public void sync(OidcUser oidcUser) { public void sync(OidcUser oidcUser) {
if (!oidcUser.hasClaim("groups")) { this.sync(oidcUser.getEmail(), oidcUser);
this.logger.warn("There is no 'groups' claim to synchronize: {}", oidcUser.getEmail()); }
this.logger.debug("The claims available: {}", oidcUser.getClaims().keySet());
public void sync(Jwt jwt) {
this.sync(jwt.getClaim(StandardClaimNames.EMAIL), jwt);
}
public void sync(String email, ClaimAccessor claims) {
if (!claims.hasClaim("groups")) {
this.logger.warn("There is no 'groups' claim to synchronize: {}", email);
this.logger.debug("The claims available: {}", claims.getClaims().keySet());
return; return;
} }
Set<String> oidcGroups = new HashSet<>(oidcUser.getClaimAsStringList("groups")); Set<String> oidcGroups = new HashSet<>(claims.getClaimAsStringList("groups"));
this.logger.trace("Incoming OIDC groups: {}: {}", oidcUser.getEmail(), oidcGroups); this.logger.trace("Incoming OIDC groups: {}: {}", email, oidcGroups);
oidcGroups = this.filterGroups(oidcGroups); oidcGroups = this.filterGroups(oidcGroups);
oidcGroups = this.translateGroups(oidcGroups); Set<String> translatedGroups = this.translateGroups(oidcGroups);
this.logger.debug("Filtered/translated OIDC groups: {}: {}", oidcUser.getEmail(), oidcGroups); this.logger.debug("Filtered/translated OIDC groups: {}: {}", email, translatedGroups);
long tenantId = this.tenantFinderService.findTenantId(); long tenantId = this.tenantFinderService.findTenantId();
// check Activiti groups // check Activiti groups
User user = this.userService.findUserByEmailAndTenantId(oidcUser.getEmail(), tenantId); User user = this.userService.findUserByEmailAndTenantId(email, tenantId);
if (user == null) { if (user == null) {
user = this.userService.findUserByEmail(oidcUser.getEmail()); user = this.userService.findUserByEmail(email);
if (user == null) if (user == null)
throw new UsernameNotFoundException("The user could not be found: " + oidcUser.getEmail()); throw new UsernameNotFoundException("The user could not be found: " + email);
} }
User userWithGroups = this.userService.getUser(user.getId(), true); User userWithGroups = this.userService.getUser(user.getId(), true);
this.logger.debug("Discovered user belongs to {} APS groups: {}", userWithGroups.getGroups().size(), user.getExternalId()); this.logger.debug("Discovered user belongs to {} APS groups: {}", userWithGroups.getGroups().size(), user.getExternalId());
@@ -166,7 +177,7 @@ public class GroupSyncService {
this.logger.trace("Inspecting APS group: {} => {} ({})", group.getId(), group.getName(), group.getExternalId()); this.logger.trace("Inspecting APS group: {} => {} ({})", group.getId(), group.getName(), group.getExternalId());
if (group.getExternalId() != null) { if (group.getExternalId() != null) {
String oidcGroup = this.apsGroupExternalIdToOidcGroup(group.getExternalId()); String translatedGroup = this.apsGroupExternalIdToTranslatedOidcGroup(group.getExternalId());
if (this.retenantUntenantedGroups && group.getTenantId() == null) { if (this.retenantUntenantedGroups && group.getTenantId() == null) {
this.logger.warn("Moving tenant-less APS group to tenant: {} => {}", group.getName(), tenantId); this.logger.warn("Moving tenant-less APS group to tenant: {} => {}", group.getName(), tenantId);
@@ -175,24 +186,25 @@ public class GroupSyncService {
this.groupService.save(group); this.groupService.save(group);
} }
if (oidcGroups.remove(oidcGroup)) { if (translatedGroups.remove(translatedGroup)) {
this.logger.trace("User already belongs to APS group mapped to by OIDC group: {}: {} => {}", user.getExternalId(), oidcGroup, group.getName()); this.logger.trace("User already belongs to APS group mapped to by (translated) OIDC group: {}: {} => {}", user.getExternalId(), translatedGroup, group.getName());
continue; continue;
} }
} else { } else {
String oidcGroup = this.apsGroupNameToOidcGroup(group.getName()); String translatedGroup = this.apsGroupNameToTranslatedOidcGroup(group.getName());
if (this.externalizeMatchingInternalGroups) {
this.logger.warn("Classifying internal APS group as external: {} => {}", group.getName(), this.externalIdmSource);
// register the group as external
group.setExternalId(this.oidcGroupToApsGroupExternalId(oidcGroup));
group.setLastUpdate(new Date());
this.groupService.save(group);
// internal role already existed and the user is already a member
}
if (oidcGroups.remove(oidcGroup)) { if (translatedGroups.remove(translatedGroup)) {
this.logger.trace("User already belongs to APS group mapped to by OIDC group: {}: {} => {}", user.getExternalId(), oidcGroup, group.getName()); this.logger.trace("User already belongs to APS group mapped to by (translated) OIDC group: {}: {} => {}", user.getExternalId(), translatedGroup, group.getName());
if (this.externalizeMatchingInternalGroups) {
this.logger.warn("Classifying internal APS group as external: {} => {}", group.getName(), this.externalIdmSource);
// register the group as external
group.setExternalId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup));
group.setLastUpdate(new Date());
this.groupService.save(group);
// internal role already existed and the user is already a member
}
continue; continue;
} else if (!this.syncInternalGroups) { } else if (!this.syncInternalGroups) {
this.logger.trace("Internal APS group membership sync disabled; not considering removal of user from APS group: {} => {}", user.getExternalId(), group.getName()); this.logger.trace("Internal APS group membership sync disabled; not considering removal of user from APS group: {} => {}", user.getExternalId(), group.getName());
@@ -211,21 +223,21 @@ public class GroupSyncService {
} }
// the user needs to be added to the remaining authorities // the user needs to be added to the remaining authorities
for (String oidcGroup : oidcGroups) { for (String translatedGroup : translatedGroups) {
this.logger.trace("Inspecting unaccounted for OIDC group: {}", oidcGroup); this.logger.trace("Inspecting unaccounted for (translated) OIDC group: {}", translatedGroup);
Group group; Group group;
try { try {
group = this.groupService.getGroupByExternalIdAndTenantId(this.oidcGroupToApsGroupExternalId(oidcGroup), tenantId); group = this.groupService.getGroupByExternalIdAndTenantId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup), tenantId);
} catch (NonUniqueResultException nure) { } catch (NonUniqueResultException nure) {
this.logger.warn("There are multiple groups matching the OIDC group for the external system: {} [{}]; skipping consideration of OIDC group", oidcGroup, this.externalIdmSource); this.logger.warn("There are multiple groups matching the (translated) OIDC group for the external system: {} [{}]; skipping consideration of OIDC group", translatedGroup, this.externalIdmSource);
continue; continue;
} }
if (group == null && this.syncInternalGroups) { if (group == null && this.syncInternalGroups) {
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.oidcGroupToApsGroupName(oidcGroup), tenantId); List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.translatedOidcGroupToApsGroupName(translatedGroup), tenantId);
if (groups.size() > 1) { if (groups.size() > 1) {
this.logger.warn("There are multiple APS groups matching the OIDC group: {} [{}]; skipping consideration of OIDC group", oidcGroup, this.externalIdmSource); this.logger.warn("There are multiple APS groups matching the (translated) OIDC group: {} [{}]; skipping consideration of OIDC group", translatedGroup, this.externalIdmSource);
continue; continue;
} else if (groups.size() == 1) { } else if (groups.size() == 1) {
group = groups.iterator().next(); group = groups.iterator().next();
@@ -233,7 +245,7 @@ public class GroupSyncService {
if (this.externalizeMatchingInternalGroups) { if (this.externalizeMatchingInternalGroups) {
this.logger.debug("Found an internal APS group; registering as external: {}", group.getName()); this.logger.debug("Found an internal APS group; registering as external: {}", group.getName());
group.setExternalId(this.oidcGroupToApsGroupExternalId(oidcGroup)); group.setExternalId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup));
group.setLastSyncTimeStamp(new Date()); group.setLastSyncTimeStamp(new Date());
group.setLastUpdate(new Date()); group.setLastUpdate(new Date());
this.groupService.save(group); this.groupService.save(group);
@@ -243,11 +255,11 @@ public class GroupSyncService {
if (group == null) { if (group == null) {
if (!this.createMissing) { if (!this.createMissing) {
this.logger.debug("APS Group does not exist for OIDC group; APS group creation is disabled; OIDC group will go unrecognized: {}", oidcGroup); this.logger.debug("APS Group does not exist for (translated) OIDC group; APS group creation is disabled; OIDC group will go unrecognized: {}", translatedGroup);
continue; continue;
} }
group = this.createApsGroup(oidcGroup, tenantId); group = this.createApsGroup(translatedGroup, tenantId);
} }
if (this.syncAdditions) { if (this.syncAdditions) {
@@ -260,13 +272,13 @@ public class GroupSyncService {
} }
} }
protected Group createApsGroup(String oidcGroup, long tenantId) { protected Group createApsGroup(String translatedGroup, long tenantId) {
this.logger.debug("APS Group does not exist for OIDC group; will attempt to create: {}", oidcGroup); this.logger.debug("APS Group does not exist for (translated) OIDC group; will attempt to create: {}", translatedGroup);
String name = this.oidcGroupToApsGroupName(oidcGroup); String name = this.translatedOidcGroupToApsGroupName(translatedGroup);
String externalId = this.oidcGroupToApsGroupExternalId(oidcGroup); String externalId = this.translatedOidcGroupToApsGroupExternalId(translatedGroup);
boolean syncAsOrg = this.isOidcGroupToBeOrganization(oidcGroup); boolean syncAsOrg = this.isTranslatedOidcGroupToBeOrganization(translatedGroup);
this.logger.trace("Creating new APS group as {}: {}", syncAsOrg ? "organization" : "capability", oidcGroup); this.logger.trace("Creating new APS group as {}: {}", syncAsOrg ? "organization" : "capability", translatedGroup);
int type = syncAsOrg ? Group.TYPE_FUNCTIONAL_GROUP : Group.TYPE_SYSTEM_GROUP; int type = syncAsOrg ? Group.TYPE_FUNCTIONAL_GROUP : Group.TYPE_SYSTEM_GROUP;
Group apsGroup = this.groupService.createGroupFromExternalStore(name, tenantId, type, null, externalId, new Date()); Group apsGroup = this.groupService.createGroupFromExternalStore(name, tenantId, type, null, externalId, new Date());
@@ -330,29 +342,29 @@ public class GroupSyncService {
return translatedGroups; return translatedGroups;
} }
private String oidcGroupToApsGroupExternalId(String group) { private String translatedOidcGroupToApsGroupExternalId(String group) {
return this.externalIdmSource + "_" + group; return this.externalIdmSource + "_" + group;
} }
private String apsGroupExternalIdToOidcGroup(String externalId) { private String apsGroupExternalIdToTranslatedOidcGroup(String externalId) {
int underscorePos = externalId.indexOf('_'); int underscorePos = externalId.indexOf('_');
return underscorePos < 0 ? externalId : externalId.substring(underscorePos + 1); return underscorePos < 0 ? externalId : externalId.substring(underscorePos + 1);
} }
private String oidcGroupToApsGroupName(String group) { private String translatedOidcGroupToApsGroupName(String group) {
return group; return group;
} }
private String apsGroupNameToOidcGroup(String externalId) { private String apsGroupNameToTranslatedOidcGroup(String externalId) {
return externalId; return externalId;
} }
private boolean isOidcGroupToBeOrganization(String role) { private boolean isTranslatedOidcGroupToBeOrganization(String translatedGroup) {
if (this.capabilities.isEmpty()) if (this.capabilities.isEmpty())
return true; return true;
for (Pattern regex : this.capabilities) { for (Pattern regex : this.capabilities) {
Matcher matcher = regex.matcher(role); Matcher matcher = regex.matcher(translatedGroup);
if (matcher.matches()) if (matcher.matches())
return false; return false;
} }

View File

@@ -0,0 +1,11 @@
package com.inteligr8.activiti.auth.service;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.oauth2.jwt.Jwt;
public interface JwtAuthenticationProvider {
Converter<Jwt, AbstractAuthenticationToken> create();
}

View File

@@ -0,0 +1,50 @@
package com.inteligr8.activiti.auth.service;
import java.util.ArrayList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.Jwt;
import com.activiti.security.identity.service.config.JwtAuthenticationToken;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
public class SyncingJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final UserDetailsService userDetailsService;
private final UserSyncService userSyncService;
private final GroupSyncService groupSyncService;
public SyncingJwtAuthenticationConverter(UserDetailsService userDetailsService, UserSyncService userSyncService, GroupSyncService groupSyncService) {
this.userDetailsService = userDetailsService;
this.userSyncService = userSyncService;
this.groupSyncService = groupSyncService;
}
@Override
public AbstractAuthenticationToken convert(Jwt source) {
this.logger.trace("convert({}, {})", source.getId(), source.getClaim("email"));
try {
this.logger.debug("jwt: {}", new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(source));
} catch (JsonProcessingException jpe) {
this.logger.error("error", jpe);
}
this.userSyncService.sync(source);
this.groupSyncService.sync(source);
UserDetails springUser = this.userDetailsService.loadUserByUsername(source.getClaim("email"));
return new JwtAuthenticationToken(
springUser,
new ArrayList<>(springUser.getAuthorities()));
}
}

View File

@@ -0,0 +1,27 @@
package com.inteligr8.activiti.auth.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
@Component
public class SyncingJwtAuthenticationProvider implements JwtAuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private UserSyncService userSyncService;
@Autowired
private GroupSyncService groupSyncService;
@Override
public Converter<Jwt, AbstractAuthenticationToken> create() {
return new SyncingJwtAuthenticationConverter(this.userDetailsService, this.userSyncService, this.groupSyncService);
}
}

View File

@@ -20,12 +20,16 @@ import com.activiti.security.identity.service.config.IdentityServiceKeycloakProp
* Activiti Identity Service configuration is enabled. When it isn't * Activiti Identity Service configuration is enabled. When it isn't
* enabled, it will still serve as the default OIDC user service for * enabled, it will still serve as the default OIDC user service for
* Spring Security. * Spring Security.
*
* This is only executed with non-API authentication and authorization use
* cases. API authentication/authorization uses the
* `SyncingJwtAuthenitcationConverter`.
*/ */
@Component @Component
@Primary @Primary
public class OIDCUserService extends OidcUserService { public class SyncingUserService extends OidcUserService {
private final Logger logger = LoggerFactory.getLogger(OIDCUserService.class); private final Logger logger = LoggerFactory.getLogger(SyncingUserService.class);
@Autowired @Autowired
private UserDetailsService userDetailsService; private UserDetailsService userDetailsService;

View File

@@ -9,7 +9,10 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.activiti.domain.idm.Group; import com.activiti.domain.idm.Group;
@@ -36,20 +39,20 @@ public class UserSyncService {
@Autowired @Autowired
private TenantFinderService tenantFinderService; private TenantFinderService tenantFinderService;
@Value("${auth-ext.sync.externalId:oauth}") @Value("${auth-ext.externalId:oauth}")
protected String externalIdmSource; protected String externalIdmSource;
@Value("${auth-ext.sync.user.createMissing:true}") @Value("${auth-ext.sync.user.createMissing:true}")
protected boolean createMissingUser; protected boolean createMissingUser;
@Value("${auth-ext.sync.user.requireGroup:#{null}}") @Value("${auth-ext.sync.user.requireOidcGroup:#{null}}")
protected String requiredGroup; protected String requiredGroup;
@Value("${auth-ext.sync.user.clearNewUserGroups:true}") @Value("${auth-ext.sync.user.clearNewUserGroups:true}")
protected boolean clearNewUserGroups; protected boolean clearNewUserGroups;
public void sync(OidcUser oidcUser) { public void sync(OidcUser oidcUser) {
UserDetails springUser = this.loadSpringUser(oidcUser); UserDetails springUser = this.loadSpringUser(oidcUser.getEmail(), oidcUser.getGivenName(), oidcUser.getFamilyName(), oidcUser);
if (this.logger.isTraceEnabled()) { if (this.logger.isTraceEnabled()) {
this.logger.trace("Loaded Spring Security user: {}: {}", springUser.getUsername(), springUser.getAuthorities()); this.logger.trace("Loaded Spring Security user: {}: {}", springUser.getUsername(), springUser.getAuthorities());
} else { } else {
@@ -57,42 +60,58 @@ public class UserSyncService {
} }
} }
private UserDetails loadSpringUser(OidcUser oidcUser) throws UsernameNotFoundException { public void sync(Jwt jwt) {
String email = jwt.getClaim(StandardClaimNames.EMAIL);
if (email == null)
throw new IllegalArgumentException("An '" + StandardClaimNames.EMAIL + "' claim is required");
String givenName = jwt.getClaim(StandardClaimNames.GIVEN_NAME);
String familyName = jwt.getClaim(StandardClaimNames.FAMILY_NAME);
UserDetails springUser = this.loadSpringUser(email, givenName, familyName, jwt);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Loaded Spring Security user: {}: {}", springUser.getUsername(), springUser.getAuthorities());
} else {
this.logger.debug("Loaded Spring Security user: {}", springUser.getUsername());
}
}
private UserDetails loadSpringUser(String email, String givenName, String familyName, ClaimAccessor claims) throws UsernameNotFoundException {
try { try {
UserDetails springUser = this.userDetailsService.loadUserByUsername(oidcUser.getEmail()); UserDetails springUser = this.userDetailsService.loadUserByUsername(email);
this.logger.debug("Loaded APS user: {} => {}", oidcUser.getEmail(), springUser.getUsername()); this.logger.debug("Loaded APS user: {} => {}", email, springUser.getUsername());
return springUser; return springUser;
} catch (UsernameNotFoundException unfe) { } catch (UsernameNotFoundException unfe) {
this.logger.debug("User does not exist: {}", unfe.getMessage()); this.logger.debug("User does not exist: {}", unfe.getMessage());
if (!this.createMissingUser) if (!this.createMissingUser)
throw unfe; throw unfe;
if (this.requiredGroup != null && (!oidcUser.hasClaim("groups") || !oidcUser.getClaimAsStringList("groups").contains(this.requiredGroup))) { if (this.requiredGroup != null && (!claims.hasClaim("groups") || !claims.getClaimAsStringList("groups").contains(this.requiredGroup))) {
this.logger.info("User does not exist and does not have the required OIDC group to be created: {} ", oidcUser.getEmail(), this.requiredGroup); this.logger.info("User does not exist and does not have the required OIDC group to be created: {} ", email, this.requiredGroup);
throw unfe; throw unfe;
} }
this.logger.debug("User does not exist; will attempt to create: {}", oidcUser.getEmail()); this.logger.debug("User does not exist; will attempt to create: {}", email);
User apsUser = this.createApsUser(oidcUser); User apsUser = this.createApsUser(email, givenName, familyName);
if (this.clearNewUserGroups) { if (this.clearNewUserGroups) {
apsUser = this.userService.getUser(apsUser.getId(), true); apsUser = this.userService.getUser(apsUser.getId(), true);
if (this.logger.isDebugEnabled()) if (this.logger.isDebugEnabled())
this.logger.debug("User is new; clearing default groups: {}: {}", oidcUser.getEmail(), apsUser.getGroups().stream().map(group -> group.getName()).toList()); this.logger.debug("User is new; clearing default groups: {}: {}", email, apsUser.getGroups().stream().map(group -> group.getName()).toList());
this.deleteApsUserGroups(apsUser); this.deleteApsUserGroups(apsUser);
} }
return this.userDetailsService.loadByUserId(apsUser.getId()); return this.userDetailsService.loadByUserId(apsUser.getId());
} }
} }
private User createApsUser(OidcUser oidcUser) { private User createApsUser(String email, String givenName, String familyName) {
long tenantId = this.tenantFinderService.findTenantId(); long tenantId = this.tenantFinderService.findTenantId();
User user = this.userService.createNewUserFromExternalStore( User user = this.userService.createNewUserFromExternalStore(
oidcUser.getEmail(), email,
oidcUser.getGivenName(), givenName,
oidcUser.getFamilyName(), familyName,
tenantId, tenantId,
oidcUser.getEmail(), email,
this.externalIdmSource, this.externalIdmSource,
new Date()); new Date());
this.logger.info("Created user: {} => {}", user.getId(), user.getEmail()); this.logger.info("Created user: {} => {}", user.getId(), user.getEmail());

View File

@@ -101,14 +101,14 @@
"profile", "profile",
"roles", "roles",
"basic", "basic",
"email" "email",
"microprofile-jwt"
], ],
"optionalClientScopes": [ "optionalClientScopes": [
"address", "address",
"phone", "phone",
"organization", "organization",
"offline_access", "offline_access"
"microprofile-jwt"
] ]
}, },
{ {
@@ -156,15 +156,76 @@
"profile", "profile",
"roles", "roles",
"basic", "basic",
"email" "email",
"microprofile-jwt"
], ],
"optionalClientScopes": [ "optionalClientScopes": [
"address", "address",
"phone", "phone",
"organization", "organization",
"offline_access", "offline_access"
"microprofile-jwt"
] ]
},
{
"clientId": "cli",
"name": "Command Line Tools",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "eJa5W7bv4ohFbr7QRtaCk0eccRFoYM5x",
"redirectUris": [
"/*"
],
"webOrigins": [
"/*"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"realm_client": "false",
"oidc.ciba.grant.enabled": "false",
"client.secret.creation.time": "1747506410",
"backchannel.logout.session.required": "true",
"standard.token.exchange.enabled": "true",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.revoke.offline.tokens": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"basic",
"email",
"microprofile-jwt"
],
"optionalClientScopes": [
"address",
"phone",
"organization",
"offline_access"
],
"access": {
"view": true,
"configure": true,
"manage": true
}
} }
], ],
"users": [ "users": [

View File

@@ -0,0 +1,35 @@
@keycloakRealm = my-app
@keycloakBaseUrl = http://localhost:8081
@oauthUrl = {{keycloakBaseUrl}}/realms/{{keycloakRealm}}
@keycloakTokenUrl = {{oauthUrl}}/protocol/openid-connect/token
@oauthClientId = cli
@oauthClientSecret = eJa5W7bv4ohFbr7QRtaCk0eccRFoYM5x
@apsBaseUrl = http://localhost:8080/activiti-app
### Token
# @name token
curl -LX POST {{keycloakTokenUrl}} \
-H 'Content-type: application/x-www-form-urlencoded' \
-d "grant_type=client_credentials" \
-d "client_id={{oauthClientId}}" \
-d "client_secret={{oauthClientSecret}}"
@accessToken = {{token.response.body.access_token}}
@auth = Bearer {{accessToken}}
### APS Version
# @name version
GET {{apsBaseUrl}}/api/enterprise/app-version
Authorization: Bearer {{accessToken}}
### APS Tenants
# @name tenants
GET {{apsBaseUrl}}/api/enterprise/admin/tenants
Authorization: Bearer {{accessToken}}
@tenantId = {{tenants.response.body.0.id}}
### APS Templates
# @name templates
GET {{apsBaseUrl}}/app/rest/document-templates?tenantId={{tenantId}}&start=0&size=10&sort=sort_by_name_asc
Authorization: Bearer {{accessToken}}