diff --git a/.github/workflows/pull-request-from-branch-check.yaml b/.github/workflows/pull-request-from-branch-check.yaml new file mode 100644 index 00000000..77d1970f --- /dev/null +++ b/.github/workflows/pull-request-from-branch-check.yaml @@ -0,0 +1,18 @@ +name: Main Branch Protection + +on: + pull_request: + branches: + - main + +jobs: + check-branch: + runs-on: ubuntu-latest + steps: + - name: Check branch + run: | + if [[ ${GITHUB_HEAD_REF} != development ]] && [[ ${GITHUB_HEAD_REF} != documentation ]] && ! [[ ${GITHUB_HEAD_REF} =~ ^hotfix/ ]]; + then + echo "Error: Pull request must come from 'development', 'documentation' or 'hotfix/' branch" + exit 1 + fi diff --git a/.github/workflows/pull-request-lint-check.yaml b/.github/workflows/pull-request-lint-check.yaml new file mode 100644 index 00000000..4a8c874f --- /dev/null +++ b/.github/workflows/pull-request-lint-check.yaml @@ -0,0 +1,21 @@ +name: Lint Check + +on: + pull_request: + branches: + - development + - main + +jobs: + lint-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Install dependencies + run: npm i + + - name: Linting + run: npm run lint diff --git a/README.md b/README.md index baa1e161..fa2d5666 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # OpenConector Provides gateway and service bus functionality like mapping, translation and synchronisation of data + diff --git a/appinfo/info.xml b/appinfo/info.xml index 6a8bbce0..260f2fe6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -13,7 +13,7 @@ The OpenConnector Nextcloud app provides a ESB-framework to work together in an - 🆓 Map and translate API calls ]]> - 0.1.14 + 0.1.16 agpl integration Conduction diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..d3bb7632 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,88 @@ +# Authentication on sources + +In order to authenticate on other sources there are possibilities based upon the way the source expects autentication parameters. +These parameters can be set in the source configuration. For example, if the source expects an API key in the headers, we can set the parameter `headers.Authorization` with the API key as value. + +Usually, sources tend to use dynamic authorization parameters in order to prevent the same authentication parameter from being used by adversaries that catch a call and deduce the parameter. + +At the moment, OpenConnector supports two dynamic authentication methods, OAuth and JWT Bearers. + +## OAuth + +To use OAuth we put in our Authorization header the following value: +```twig +Bearer {{ oauthToken(source) }} +``` +This will impose an OAuth 2.0 access token after `Bearer` if the field `authenticationConfig` contains correct values. +OpenConnector supports the OAuth 2.0 protocol with client credentials and password credentials as grant_types. + +>[!NOTE] +> TODO: How to add authenticationConfig parameters in frontend + +When using OAuth, OpenConnector supports the following parameters: + +### Standard parameters +* `grant_type`: The type of grant we have to use at the source. Supported are `client_credentials` and `password` +* `scope`: The scope(s) needed to perform the requests we want to do in the API. +* `tokenUrl`: The URL used to fetch the actual access token. Usually this url can be recognised by its path ending on `/oauth/token` +* `authentication`: Location of the credentials, either `body` for credentials included in the request body, or `basic_auth` when the credentials have to be sent as a basic_auth header. + > Only used when `grant_type` is `client_credentials` +* `client_id`: The client id of the OAuth client + > Only used when `grant_type` is `client_credentials` +* `client_secret`: The secret for the OAuth client + > Only used when `grant_type` is `client_credentials` +* `username`: The username for the OAuth client + > Only used when `grant_type` is `password` +* `password`: The password for the OAuth client + > Only used when `grant_type` is `password` + +This results in the following example: +```json +{ + "grant_type": "client_credentials", + "scope": "api", + "authentication": "body", + "tokenUrl": "https://example.com/oauth/token", + "client_id": "test-client", + "client_secret": "some secret value" +} +``` +### Custom parameters + +> [!WARNING] +> Custom parameters are currently in beta, it is not recommended to use them in production environments. + +At the moment, OpenConnector is tested with the following custom parameters: + +* `client_assertion_type`, only meaningful at the moment when value is set to `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. When this is set (for Microsoft authentications) the following fields are needed to generate the `client-assertion`-field + - `private_key`: The base64 encoded private key of the certificate uploaded to Microsoft. + - `x5t`: The base64 encoded sha1 fingerprint of the uploaded certificate, generated by running the following command: + + ```bash + echo $(openssl x509 -in certificate.crt -fingerprint -noout) | sed 's/SHA1 Fingerprint=//g' | sed 's/://g' | xxd -r -ps | base64`) + ``` + + - `payload`: The payload of the JWT generated as `client_assertion`, this can contain Twig variables to render, for example to set timestamps in the JWT payload. + +## JWT Bearer + +A second supported way of using dynamic authentication is setting a JWT Bearer. This means setting a header or query parameter with a JWT token. + +This can for example be used by setting an Authorization header with the following value: +```twig +Bearer {{ jwtToken(source) }} +``` + +This will impose a JWT token after the bearer. For this, the `authenticationConfig` field of the source needs to contain the following fields: +* `algorithm`: The algorithm that should be used to generate the JWT. Supported are `HS256`, `HS384` and `HS512` for HMAC algorithms, `RS256`, `RS384`, `RS512` and `PS256` for RSA algorithms. +* `secret`: The secret used for the JWT. This can either be a HMAC shared secret, or a RSA private key in base64 encoding. +* `payload`: The payload of your JWT, json_encoded. + +This results in the following example for the `authenticationConfig` parameter in i.e. an OpenZaak source. +```json +{ + "algorithm": "HS256", + "secret": "YOUR_256BIT_(32BYTE)_HMAC_SECRET", + "payload": "{\"iss\":\"my_zgw_client\",\"iat\":{{ 'now'|date('U') }},\"client_id\":\"my_zgw_client\",\"user_id\":\"my_zgw_client\",\"user_representation\":\"me@company.com\",\"aud\":\"my_zgw_client\"}" +} +``` diff --git a/lib/Service/AuthenticationService.php b/lib/Service/AuthenticationService.php index b6518edb..b59e41b4 100644 --- a/lib/Service/AuthenticationService.php +++ b/lib/Service/AuthenticationService.php @@ -327,6 +327,7 @@ public function fetchJWTToken (array $configuration): string } $payload = $this->getJWTPayload($configuration); + $jwk = $this->getJWK($configuration); if ($jwk === null) { diff --git a/lib/Service/CallService.php b/lib/Service/CallService.php index 4e7ca38a..4a86c601 100644 --- a/lib/Service/CallService.php +++ b/lib/Service/CallService.php @@ -197,6 +197,11 @@ public function call( // Set authentication if needed. @todo: create the authentication service //$createCertificates && $this->getCertificate($config); + // Make sure to filter out all the authentication variables / secrets. + $config = array_filter($config, function ($key) { + return str_contains(strtolower($key), 'authentication') === false; + }, ARRAY_FILTER_USE_KEY); + // Let's log the call. $this->source->setLastCall(new \DateTime()); // @todo: save the source diff --git a/lib/Service/SynchronizationService.php b/lib/Service/SynchronizationService.php index 920ec6e8..37fcb836 100644 --- a/lib/Service/SynchronizationService.php +++ b/lib/Service/SynchronizationService.php @@ -384,7 +384,12 @@ public function getAllObjectsFromApi(Synchronization $synchronization, ?bool $is // Make the initial API call $response = $this->callService->call(source: $source, endpoint: $endpoint, method: 'GET', config: $config)->getResponse(); + $lastHash = md5($response['body']); $body = json_decode($response['body'], true); + if (empty($body) === true) { + // @todo log that we got a empty response + return []; + } $objects = array_merge($objects, $this->getAllObjectsFromArray(array: $body, synchronization: $synchronization)); // Return a single object or empty array if in test mode @@ -412,6 +417,13 @@ public function getAllObjectsFromApi(Synchronization $synchronization, ?bool $is do { $config = $this->getNextPage(config: $config, sourceConfig: $sourceConfig, currentPage: $currentPage); $response = $this->callService->call(source: $source, endpoint: $endpoint, method: 'GET', config: $config)->getResponse(); + $hash = md5($response['body']); + + if($hash === $lastHash) { + break; + } + + $lastHash = $hash; $body = json_decode($response['body'], true); if (empty($body) === true) { @@ -484,8 +496,8 @@ public function getAllObjectsFromArray(array $array, Synchronization $synchroniz $sourceConfig = $synchronization->getSourceConfig(); // Check if a specific objects position is defined in the source configuration - if (empty($sourceConfig['objectsPosition']) === false) { - $position = $sourceConfig['objectsPosition']; + if (empty($sourceConfig['resultsPosition']) === false) { + $position = $sourceConfig['resultsPosition']; // Use Dot notation to access nested array elements $dot = new Dot($array); if ($dot->has($position) === true) { diff --git a/lib/Twig/AuthenticationRuntime.php b/lib/Twig/AuthenticationRuntime.php index 34b59ac6..1655229e 100644 --- a/lib/Twig/AuthenticationRuntime.php +++ b/lib/Twig/AuthenticationRuntime.php @@ -2,9 +2,12 @@ namespace OCA\OpenConnector\Twig; +use Adbar\Dot; use GuzzleHttp\Exception\GuzzleException; use OCA\OpenConnector\Db\Source; use OCA\OpenConnector\Service\AuthenticationService; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Twig\Extension\RuntimeExtensionInterface; class AuthenticationRuntime implements RuntimeExtensionInterface @@ -25,8 +28,12 @@ public function __construct( */ public function oauthToken(Source $source): string { + $configuration = new Dot($source->getConfiguration(), true); + + $authConfig = $configuration->get('authentication'); + return $this->authService->fetchOAuthTokens( - configuration: $source->getAuthenticationConfig() + configuration: $authConfig ); } @@ -39,8 +46,12 @@ public function oauthToken(Source $source): string */ public function jwtToken(Source $source): string { + $configuration = new Dot($source->getConfiguration(), true); + + $authConfig = $configuration->get('authentication'); + return $this->authService->fetchJWTToken( - configuration: $source->getAuthenticationConfig() + configuration: $authConfig ); } } diff --git a/src/entities/synchronization/synchronization.ts b/src/entities/synchronization/synchronization.ts index 700367e5..c9bac364 100644 --- a/src/entities/synchronization/synchronization.ts +++ b/src/entities/synchronization/synchronization.ts @@ -12,7 +12,7 @@ export class Synchronization extends ReadonlyBaseClass implements TSynchronizati public sourceType: string public sourceHash: string public sourceTargetMapping: string - public sourceConfig: object + public sourceConfig: Record public sourceLastChanged: string public sourceLastChecked: string public sourceLastSynced: string @@ -20,7 +20,7 @@ export class Synchronization extends ReadonlyBaseClass implements TSynchronizati public targetType: string public targetHash: string public targetSourceMapping: string - public targetConfig: object + public targetConfig: Record public targetLastChanged: string public targetLastChecked: string public targetLastSynced: string @@ -64,7 +64,7 @@ export class Synchronization extends ReadonlyBaseClass implements TSynchronizati sourceType: z.string(), sourceHash: z.string(), sourceTargetMapping: z.string(), - sourceConfig: z.object({}), + sourceConfig: z.record(z.string(), z.string()), sourceLastChanged: z.string(), sourceLastChecked: z.string(), sourceLastSynced: z.string(), @@ -72,7 +72,7 @@ export class Synchronization extends ReadonlyBaseClass implements TSynchronizati targetType: z.string(), targetHash: z.string(), targetSourceMapping: z.string(), - targetConfig: z.object({}), + targetConfig: z.record(z.string(), z.string()), targetLastChanged: z.string(), targetLastChecked: z.string(), targetLastSynced: z.string(), diff --git a/src/entities/synchronization/synchronization.types.ts b/src/entities/synchronization/synchronization.types.ts index e2e59bfd..7dcf47a1 100644 --- a/src/entities/synchronization/synchronization.types.ts +++ b/src/entities/synchronization/synchronization.types.ts @@ -6,7 +6,7 @@ export type TSynchronization = { sourceType: string sourceHash: string sourceTargetMapping: string - sourceConfig: object + sourceConfig: Record sourceLastChanged: string sourceLastChecked: string sourceLastSynced: string @@ -14,7 +14,7 @@ export type TSynchronization = { targetType: string targetHash: string targetSourceMapping: string - targetConfig: object + targetConfig: Record targetLastChanged: string targetLastChecked: string targetLastSynced: string diff --git a/src/modals/Log/ViewSynchronizationContract.vue b/src/modals/Log/ViewSynchronizationContract.vue index 7af566fd..b0fe66b8 100644 --- a/src/modals/Log/ViewSynchronizationContract.vue +++ b/src/modals/Log/ViewSynchronizationContract.vue @@ -13,18 +13,17 @@ import { logStore, navigationStore } from '../../store/store.js' Standard - - +
+ -
- {{ key }} + {{ key }} + + {{ new Date(value).toLocaleString() }} - {{ value }} + {{ value || '-' }}
@@ -37,6 +36,8 @@ import { NcModal, } from '@nextcloud/vue' +import getValidISOstring from '../../services/getValidISOstring.js' + export default { name: 'ViewSynchronizationContract', components: { @@ -107,3 +108,16 @@ export default { } + + diff --git a/src/modals/Modals.vue b/src/modals/Modals.vue index 2e4ebf26..c0287073 100644 --- a/src/modals/Modals.vue +++ b/src/modals/Modals.vue @@ -26,16 +26,22 @@ import { navigationStore } from '../store/store.js' - - + + + + + + + + @@ -62,6 +68,8 @@ import EditJobArgument from './JobArgument/EditJobArgument.vue' import DeleteJobArgument from './JobArgument/DeleteJobArgument.vue' import EditSourceConfiguration from './SourceConfiguration/EditSourceConfiguration.vue' import DeleteSourceConfiguration from './SourceConfiguration/DeleteSourceConfiguration.vue' +import EditSourceConfigurationAuthentication from './SourceConfigurationAuthentication/EditSourceConfigurationAuthentication.vue' +import DeleteSourceConfigurationAuthentication from './SourceConfigurationAuthentication/DeleteSourceConfigurationAuthentication.vue' import ViewSourceLog from './Log/ViewSourceLog.vue' import EditMappingMapping from './mappingMapping/EditMappingMapping.vue' import DeleteMappingMapping from './mappingMapping/DeleteMappingMapping.vue' @@ -72,6 +80,10 @@ import ViewSynchronizationLog from './Log/ViewSynchronizationLog.vue' import ViewSynchronizationContract from './Log/ViewSynchronizationContract.vue' import EditMappingUnset from './mappingUnset/EditMappingUnset.vue' import DeleteMappingUnset from './mappingUnset/DeleteMappingUnset.vue' +import EditSynchronizationSourceConfig from './SynchronizationSourceConfig/EditSynchronizationSourceConfig.vue' +import DeleteSynchronizationSourceConfig from './SynchronizationSourceConfig/DeleteSynchronizationSourceConfig.vue' +import EditSynchronizationTargetConfig from './SynchronizationTargetConfig/EditSynchronizationTargetConfig.vue' +import DeleteSynchronizationTargetConfig from './SynchronizationTargetConfig/DeleteSynchronizationTargetConfig.vue' export default { name: 'Modals', @@ -98,6 +110,8 @@ export default { DeleteJobArgument, EditSourceConfiguration, DeleteSourceConfiguration, + EditSourceConfigurationAuthentication, + DeleteSourceConfigurationAuthentication, ViewSourceLog, EditMappingMapping, DeleteMappingMapping, @@ -108,6 +122,10 @@ export default { ViewSynchronizationContract, EditMappingUnset, DeleteMappingUnset, + EditSynchronizationSourceConfig, + DeleteSynchronizationSourceConfig, + EditSynchronizationTargetConfig, + DeleteSynchronizationTargetConfig, }, setup() { return { diff --git a/src/modals/SourceConfigurationAuthentication/DeleteSourceConfigurationAuthentication.vue b/src/modals/SourceConfigurationAuthentication/DeleteSourceConfigurationAuthentication.vue new file mode 100644 index 00000000..f319319e --- /dev/null +++ b/src/modals/SourceConfigurationAuthentication/DeleteSourceConfigurationAuthentication.vue @@ -0,0 +1,118 @@ + + + + + + + diff --git a/src/modals/SourceConfigurationAuthentication/EditSourceConfigurationAuthentication.vue b/src/modals/SourceConfigurationAuthentication/EditSourceConfigurationAuthentication.vue new file mode 100644 index 00000000..b12a1e15 --- /dev/null +++ b/src/modals/SourceConfigurationAuthentication/EditSourceConfigurationAuthentication.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/src/modals/Synchronization/EditSynchronization.vue b/src/modals/Synchronization/EditSynchronization.vue index 3c28da67..1a76f1b2 100644 --- a/src/modals/Synchronization/EditSynchronization.vue +++ b/src/modals/Synchronization/EditSynchronization.vue @@ -1,22 +1,78 @@