diff --git a/.azuredevops/pipelines/build-v2.yml b/.azuredevops/pipelines/build-v2.yml index f6d6cef..5f90735 100644 --- a/.azuredevops/pipelines/build-v2.yml +++ b/.azuredevops/pipelines/build-v2.yml @@ -153,6 +153,18 @@ steps: dockerComposeFile: $(Build.SourcesDirectory)/sb-mock-data-recipient/Source/docker-compose.IntegrationTests.yml dockerComposeCommand: down +# Surface Integration tests TRX results to Devops +- task: PublishTestResults@2 + displayName: 'Surface Integration tests TRX results to Devops' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'VSTest' + testResultsFiles: '**/results.trx' + searchFolder: $(Build.SourcesDirectory)/sb-mock-data-recipient/Source/_temp/mock-data-recipient-integration-tests/testresults + mergeTestResults: true + testRunTitle: 'mock-data-recipient-Integration-tests' + publishRunAttachments: true + # Run e2e tests - task: DockerCompose@0 displayName: E2E tests - Up @@ -171,6 +183,18 @@ steps: dockerComposeFile: $(Build.SourcesDirectory)/sb-mock-data-recipient/Source/docker-compose.E2ETests.yml dockerComposeCommand: down +# Surface E2E tests TRX results to Devops +- task: PublishTestResults@2 + displayName: 'Surface E2E tests TRX results to Devops' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'VSTest' + testResultsFiles: '**/results.trx' + searchFolder: $(Build.SourcesDirectory)/sb-mock-data-recipient/Source/_temp/mock-data-recipient-e2e-tests/testresults + mergeTestResults: true + testRunTitle: 'mock-data-recipient-E2E-tests' + publishRunAttachments: true + # Save docker image to TAR so it can be published - task: Docker@2 displayName: Save MockDataRecipient image to TAR @@ -265,16 +289,16 @@ steps: condition: always() artifact: Database Migration Scripts -- task: PublishTestResults@2 - displayName: 'Surface Integration Test TRX results to devops' - condition: succeededOrFailed() - inputs: - testResultsFormat: 'VSTest' # Options: JUnit, NUnit, VSTest, xUnit, cTest - testResultsFiles: '**/results.trx' - #searchFolder: '$(System.DefaultWorkingDirectory)' # Optional - #mergeTestResults: false # Optional - #failTaskOnFailedTests: false # Optional - #testRunTitle: # Optional - #buildPlatform: # Optional - #buildConfiguration: # Optional - #publishRunAttachments: true # Optional \ No newline at end of file +# - task: PublishTestResults@2 +# displayName: 'Surface Integration Test TRX results to devops' +# condition: succeededOrFailed() +# inputs: +# testResultsFormat: 'VSTest' # Options: JUnit, NUnit, VSTest, xUnit, cTest +# testResultsFiles: '**/results.trx' +# #searchFolder: '$(System.DefaultWorkingDirectory)' # Optional +# #mergeTestResults: false # Optional +# #failTaskOnFailedTests: false # Optional +# #testRunTitle: # Optional +# #buildPlatform: # Optional +# #buildConfiguration: # Optional +# #publishRunAttachments: true # Optional \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bdce1a5..b63571b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.0.0] - 2022-08-02 +## [1.0.1] - 2022-08-30 +### Changed +- Updated arrangement revocation to match CDS v1.18. Configuration added based on the date to make functionality available or unavailable. +- Updated side menu layout and text on screens. +- Updated package references. + +### Fixed +- Fixed issue with Dynamic Client Registration Azure function not retrying for DCR Failed data holders. + +## [1.0.0] - 2022-07-22 ### Added - Azure functions to perform Data Holder discovery by polling the Get Data Holder Brands API of the Register. diff --git a/LICENSE b/LICENSE index f8ca315..aa21f3d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Commonwealth of Australia +Copyright (c) 2021 Commonwealth of Australia Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 8e412b5..29e80f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Consumer Data Right Logo](https://raw.githubusercontent.com/ConsumerDataRight/mock-data-recipient/main/cdr-logo.png) -[![Consumer Data Standards v1.16.0](https://img.shields.io/badge/Consumer%20Data%20Standards-v1.16.0-blue.svg)](https://consumerdatastandardsaustralia.github.io/standards-archives/standards-1.16.0/#introduction) +[![Consumer Data Standards v1.17.0](https://img.shields.io/badge/Consumer%20Data%20Standards-v1.17.0-blue.svg)](https://consumerdatastandardsaustralia.github.io/standards/#introduction) [![Conformance Test Suite 3.2](https://img.shields.io/badge/Conformance%20Test%20Suite-v3.2-darkblue.svg)](https://www.cdr.gov.au/for-providers/conformance-test-suite-data-recipients) [![made-with-dotnet](https://img.shields.io/badge/Made%20with-.NET-1f425Ff.svg)](https://dotnet.microsoft.com/) [![made-with-csharp](https://img.shields.io/badge/Made%20with-C%23-1f425Ff.svg)](https://docs.microsoft.com/en-us/dotnet/csharp/) @@ -13,9 +13,9 @@ This project includes source code, documentation and instructions for a Consumer This repository contains a mock implementation of a Mock Data Recipient and is offered to help the community in the development and testing of their CDR solutions. ## Mock Data Recipient - Alignment -The Mock Data Recipient aligns to [v1.16.0](https://consumerdatastandardsaustralia.github.io/standards-archives/standards-1.16.0/#introduction) of the [Consumer Data Standards](https://consumerdatastandardsaustralia.github.io/standards/#introduction). +The Mock Data Recipient aligns to [v1.17.0](https://consumerdatastandardsaustralia.github.io/standards/#introduction) of the [Consumer Data Standards](https://consumerdatastandardsaustralia.github.io/standards/#introduction). The Mock Data Recipient passed v3.2 of the [Conformance Test Suite for Data Recipients](https://www.cdr.gov.au/for-providers/conformance-test-suite-data-recipients). -The Mock Data Recipient can connect to and complete authentication against both [FAPI 1.0 Migration Phase 1 and Phase 2](https://consumerdatastandardsaustralia.github.io/standards-archives/standards-1.16.0/#authentication-flows) compliant data holders. +The Mock Data Recipient can connect to and complete authentication against both [FAPI 1.0 Migration Phase 1 and Phase 2](https://consumerdatastandardsaustralia.github.io/standards/#authentication-flows) compliant data holders. ## Getting Started The Mock Data Recipient was built using the [Mock Register](https://github.com/ConsumerDataRight/mock-register), the [Mock Data Holder](https://github.com/ConsumerDataRight/mock-data-holder) and the [Mock Data Holder Energy](https://github.com/ConsumerDataRight/mock-data-holder-energy). You can swap out any of the Mock Data Holders, Mock Data Register and Mock Data Recipient solutions with a solution of your own. diff --git a/Source/CDR.DCR/CDR.DCR.csproj b/Source/CDR.DCR/CDR.DCR.csproj index 22fa3ef..35e1d04 100644 --- a/Source/CDR.DCR/CDR.DCR.csproj +++ b/Source/CDR.DCR/CDR.DCR.csproj @@ -5,7 +5,7 @@ <_FunctionsSkipCleanOutput>true - + diff --git a/Source/CDR.DCR/DCR.cs b/Source/CDR.DCR/DCR.cs index 16ac3b5..2c62250 100644 --- a/Source/CDR.DCR/DCR.cs +++ b/Source/CDR.DCR/DCR.cs @@ -33,12 +33,13 @@ public static class DynamicClientRegistrationFunction [FunctionName("FunctionDCR")] public static async Task DCR([QueueTrigger("dynamicclientregistration", Connection = "StorageConnectionString")] CloudQueueMessage myQueueItem, ILogger log, ExecutionContext context) { - string msg = ""; - string infosecBaseUri = ""; - string regEndpoint = ""; + string msg = string.Empty; + string dataHolderBrandName = string.Empty; + string infosecBaseUri = string.Empty; + string regEndpoint = string.Empty; DcrQueueMessage myQMsg = JsonConvert.DeserializeObject(myQueueItem.AsString); - + try { var isLocalDev = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT").Equals("Development"); @@ -98,9 +99,9 @@ public static async Task DCR([QueueTrigger("dynamicclientregistration", Connecti { var ssa = await GetSoftwareStatementAssertion(ssaEndpoint, xv, tokenResponse.Data.AccessToken, clientCertificate, brandId, softwareProductId, log, ignoreServerCertificateErrors); if (ssa.IsSuccessful) - { - // DOES the Data Holder EXIST in the REPO? - DataHolderBrand dh = await new SqlDataAccess(dbConnString).GetDHBrandById(myQMsg.DataHolderBrandId); + { + //DOES the Data Holder Brand EXIST in the REPO? + DataHolderBrand dh = await new SqlDataAccess(dbConnString).GetDataHolderBrand(myQMsg.DataHolderBrandId); if (dh == null) { // NO - DOES the DcrMessage exist? @@ -115,12 +116,13 @@ public static async Task DCR([QueueTrigger("dynamicclientregistration", Connecti MessageState = MessageEnum.DCRFailed.ToString(), MessageError = $"{msg} - does not exist in the repo" }; - await new SqlDataAccess(dbConnString).UpdateDcrMsgReplaceMessageId(dcrMsg, myQueueItem.Id); + await new SqlDataAccess(dbConnString).UpdateDcrMsgReplaceMessageIdWithoutBrand(dcrMsg, myQueueItem.Id); } await InsertLog(log, dbConnString, $"{msg} - does not exist in the repo", "Error", "DCR"); } else { + dataHolderBrandName = dh.BrandName; // YES - DOES a Registration already exist for the DataHolderBrandId in the local repo? Guid clientId = await new SqlDataAccess(dbConnString).GetRegByDHBrandId(dh.DataHolderBrandId); if (clientId == Guid.Empty) @@ -164,6 +166,8 @@ public static async Task DCR([QueueTrigger("dynamicclientregistration", Connecti { ClientId = regClientId, DataHolderBrandId = new Guid(dh.DataHolderBrandId), + BrandName = dh.BrandName, + InfosecBaseUri = infosecBaseUri, MessageState = MessageEnum.DCRComplete.ToString() }; await new SqlDataAccess(dbConnString).UpdateDcrMsgByDHBrandId(dcrMsg); @@ -182,6 +186,8 @@ public static async Task DCR([QueueTrigger("dynamicclientregistration", Connecti DcrMessage dcrMsg = new() { DataHolderBrandId = new Guid(dh.DataHolderBrandId), + BrandName = dh.BrandName, + InfosecBaseUri = infosecBaseUri, MessageState = MessageEnum.DCRFailed.ToString(), MessageError = $"StatusCode: {regStatusCode}, {regMessage}" }; @@ -195,6 +201,8 @@ public static async Task DCR([QueueTrigger("dynamicclientregistration", Connecti DcrMessage dcrMsg = new() { DataHolderBrandId = new Guid(myQMsg.DataHolderBrandId), + BrandName = dh.BrandName, + InfosecBaseUri = infosecBaseUri, MessageState = MessageEnum.DCRFailed.ToString(), MessageError = "OidcDiscovery failed InfosecBaseUri: " + infosecBaseUri }; @@ -233,6 +241,8 @@ public static async Task DCR([QueueTrigger("dynamicclientregistration", Connecti DcrMessage dcrMsg = new() { DataHolderBrandId = new Guid(myQMsg.DataHolderBrandId), + BrandName = dataHolderBrandName, + InfosecBaseUri = infosecBaseUri, MessageState = MessageEnum.DCRFailed.ToString(), MessageError = ex.Message }; @@ -245,7 +255,12 @@ public static async Task DCR([QueueTrigger("dynamicclientregistration", Connecti if (!string.IsNullOrEmpty(regEndpoint)) extraMsg = " - RegistrationEndpoint: " + regEndpoint; - await InsertLog(log, dbLoggingConnString, $"{msg}, REGISTRATION FAILED{extraMsg}", "Exception", "DCR", ex); + if (ex is JsonReaderException) + { + await InsertLog(log, dbLoggingConnString, $"{msg}, REGISTRATION FAILED: OidcDiscovery can't be desiearlized {extraMsg}", "Exception", "DCR", ex); + } + else + await InsertLog(log, dbLoggingConnString, $"{msg}, REGISTRATION FAILED{extraMsg}", "Exception", "DCR", ex); } } diff --git a/Source/CDR.DataRecipient.E2ETests/AATestPlaywrightInstallation.cs b/Source/CDR.DataRecipient.E2ETests/AATestPlaywrightInstallation.cs index 1cec90b..a794a57 100644 --- a/Source/CDR.DataRecipient.E2ETests/AATestPlaywrightInstallation.cs +++ b/Source/CDR.DataRecipient.E2ETests/AATestPlaywrightInstallation.cs @@ -10,6 +10,8 @@ public class AATestPlaywrightInstallation : BaseTest_v2, IClassFixture { // Act - Goto Google.com diff --git a/Source/CDR.DataRecipient.E2ETests/BaseTest_v2.cs b/Source/CDR.DataRecipient.E2ETests/BaseTest_v2.cs index 837ddb7..67d8114 100644 --- a/Source/CDR.DataRecipient.E2ETests/BaseTest_v2.cs +++ b/Source/CDR.DataRecipient.E2ETests/BaseTest_v2.cs @@ -1,4 +1,4 @@ -// #define TEST_DEBUG_MODE // Run Playwright in non-headless mode for debugging purposes (ie show a browser) +#define TEST_DEBUG_MODE // Run Playwright in non-headless mode for debugging purposes (ie show a browser) // In docker (Ubuntu container) Playwright will fail if running in non-headless mode, so we ensure TEST_DEBUG_MODE is undef'ed #if !DEBUG @@ -28,6 +28,8 @@ namespace CDR.DataRecipient.E2ETests [DisplayTestMethodName] public class BaseTest_v2 { + static public bool RUNNING_IN_CONTAINER => Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")?.ToUpper() == "TRUE"; + // Customers public const string CUSTOMERID_BANKING = "jwilson"; public const string CUSTOMERACCOUNTS_BANKING = "Personal Loan xxx-xxx xxxxx987,Transactions and Savings Account xxx-xxx xxxxx988"; diff --git a/Source/CDR.DataRecipient.E2ETests/CDR.DataRecipient.E2ETests.csproj b/Source/CDR.DataRecipient.E2ETests/CDR.DataRecipient.E2ETests.csproj index f1ae0df..6ccc1cb 100644 --- a/Source/CDR.DataRecipient.E2ETests/CDR.DataRecipient.E2ETests.csproj +++ b/Source/CDR.DataRecipient.E2ETests/CDR.DataRecipient.E2ETests.csproj @@ -3,9 +3,9 @@ net6.0 false - 1.0.0 - 1.0.0 - 1.0.0 + 1.0.1 + 1.0.1 + 1.0.1 @@ -29,13 +29,13 @@ - - + + - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Source/CDR.DataRecipient.E2ETests/Fixtures/TestFixture.cs b/Source/CDR.DataRecipient.E2ETests/Fixtures/TestFixture.cs index cb9cd59..ee45bfc 100644 --- a/Source/CDR.DataRecipient.E2ETests/Fixtures/TestFixture.cs +++ b/Source/CDR.DataRecipient.E2ETests/Fixtures/TestFixture.cs @@ -11,9 +11,13 @@ public class TestFixture : IAsyncLifetime { public Task InitializeAsync() { - // Ensure that Playwright has been fully installed. - Microsoft.Playwright.Program.Main(new string[] { "install" }); - Microsoft.Playwright.Program.Main(new string[] { "install-deps" }); + // Only install Playwright if not running in container, since Dockerfile.e2e-tests already installed Playwright + if (!BaseTest_v2.RUNNING_IN_CONTAINER) + { + // Ensure that Playwright has been fully installed. + Microsoft.Playwright.Program.Main(new string[] { "install" }); + Microsoft.Playwright.Program.Main(new string[] { "install-deps" }); + } return Task.CompletedTask; } diff --git a/Source/CDR.DataRecipient.E2ETests/US23863_MDR_E2ETests_v2.cs b/Source/CDR.DataRecipient.E2ETests/US23863_MDR_E2ETests_v2.cs index 998bf66..77fb19e 100644 --- a/Source/CDR.DataRecipient.E2ETests/US23863_MDR_E2ETests_v2.cs +++ b/Source/CDR.DataRecipient.E2ETests/US23863_MDR_E2ETests_v2.cs @@ -207,9 +207,9 @@ await TestAsync($"{nameof(US23863_MDR_E2ETests_v2)} - {nameof(AC01_HomePage)}", await page.Locator("a >> text=Dynamic Client Registration").TextContentAsync(); await page.Locator("a >> text=Consent and Authorisation").TextContentAsync(); await page.Locator("a >> text=Consents").TextContentAsync(); - await page.Locator("a >> text=Consumer Data Sharing - Common").TextContentAsync(); - await page.Locator("a >> text=Consumer Data Sharing - Banking").TextContentAsync(); - await page.Locator("a >> text=Consumer Data Sharing - Energy").TextContentAsync(); + await page.Locator("span:text(\"Consumer Data Sharing\") + ul >> a:text(\"Common\")").TextContentAsync(); + await page.Locator("span:text(\"Consumer Data Sharing\") + ul >> a:text(\"Banking\")").TextContentAsync(); + await page.Locator("span:text(\"Consumer Data Sharing\") + ul >> a:text(\"Energy\")").TextContentAsync(); await page.Locator("a >> text=PAR").TextContentAsync(); await page.Locator("span >> text=Utilities").TextContentAsync(); await page.Locator("a >> text=ID Token Helper").TextContentAsync(); @@ -769,8 +769,8 @@ await ArrangeAsync(async (page) => await TestAsync($"{nameof(US23863_MDR_E2ETests_v2)} - {nameof(AC08_ConsumerDataSharing_Banking)}", async (page) => { // Arrange - Goto home page, click menu button, check page loaded - await page.GotoAsync(WEB_URL); - await page.Locator("a >> text=Consumer Data Sharing - Banking").ClickAsync(); + await page.GotoAsync(WEB_URL); + await page.Locator("span:text(\"Consumer Data Sharing\") + ul >> a:text(\"Banking\")").ClickAsync(); await page.Locator("h2 >> text=Data Sharing - Banking").TextContentAsync(); await Task.Delay(2000); // give screen time to refresh }); @@ -807,7 +807,7 @@ await TestAsync($"{nameof(US23863_MDR_E2ETests_v2)} - {nameof(AC08_ConsumerDataS { // Arrange - Goto home page, click menu button, check page loaded await page.GotoAsync(WEB_URL); - await page.Locator("a >> text=Consumer Data Sharing - Banking").ClickAsync(); + await page.Locator("span:text(\"Consumer Data Sharing\") + ul >> a:text(\"Banking\")").ClickAsync(); await page.Locator("h2 >> text=Data Sharing - Banking").TextContentAsync(); // Arrange - Get Swagger iframe @@ -874,7 +874,7 @@ await TestAsync($"{nameof(US23863_MDR_E2ETests_v2)} - {nameof(AC08_ConsumerDataS { // Arrange - Goto home page, click menu button, check page loaded await page.GotoAsync(WEB_URL); - await page.Locator("a >> text=Consumer Data Sharing - Energy").ClickAsync(); + await page.Locator("span:text(\"Consumer Data Sharing\") + ul >> a:text(\"Energy\")").ClickAsync(); await page.Locator("h2 >> text=Data Sharing - Energy").TextContentAsync(); await Task.Delay(2000); // give screen time to refresh }); @@ -911,7 +911,7 @@ await TestAsync($"{nameof(US23863_MDR_E2ETests_v2)} - {nameof(AC08_ConsumerDataS { // Arrange - Goto home page, click menu button, check page loaded await page.GotoAsync(WEB_URL); - await page.Locator("a >> text=Consumer Data Sharing - Energy").ClickAsync(); + await page.Locator("span:text(\"Consumer Data Sharing\") + ul >> a:text(\"Energy\")").ClickAsync(); await page.Locator("h2 >> text=Data Sharing - Energy").TextContentAsync(); // Arrange - Get Swagger iframe diff --git a/Source/CDR.DataRecipient.E2ETests/appsettings.Development-WithDockerCompose.json b/Source/CDR.DataRecipient.E2ETests/appsettings.Development-WithDockerCompose.json new file mode 100644 index 0000000..2522147 --- /dev/null +++ b/Source/CDR.DataRecipient.E2ETests/appsettings.Development-WithDockerCompose.json @@ -0,0 +1,25 @@ +{ + "ConnectionStrings": { + "DataHolder": "Server=localhost,9933;Database=cdr-mdh;User Id='SA';Password='Pa{}w0rd2019';MultipleActiveResultSets=True", + "DataHolderEnergy": "Server=localhost,9933;Database=cdr-mdhe;User Id='SA';Password='Pa{}w0rd2019';MultipleActiveResultSets=True", + "DataHolderIdentityServer": "Server=localhost,9933;Database=cdr-idsvr;User Id='SA';Password='Pa{}w0rd2019';MultipleActiveResultSets=True", + "DataHolderEnergyIdentityServer": "Server=localhost,9933;Database=cdr-idsvre;User Id='SA';Password='Pa{}w0rd2019';MultipleActiveResultSets=True", + "Register": "Server=localhost,9933;Database=cdr-register;User Id='SA';Password='Pa{}w0rd2019';MultipleActiveResultSets=True", + "DataRecipient": "Server=localhost,9933;Database=cdr-mdr;User Id='SA';Password='Pa{}w0rd2019';MultipleActiveResultSets=True" + }, + "CreateMedia": true, + "MediaFolder": "C:\\cdr\\Media", + "Web_URL": "https://mock-data-recipient:9001", + "Hostnames": { + "Register": "mock-register", + "DataHolder": "mock-data-holder", + "DataHolderEnergy": "mock-data-holder-energy", + "DataRecipient": "mock-data-recipient" + }, + "DataHolder": { + "AccessTokenLifetimeSeconds": 3600 + }, + "TLS_BaseURL": "https://mock-register:7000", + "MTLS_BaseURL": "https://mock-register:7001", + "Admin_BaseURL": "https://mock-register:7006" +} \ No newline at end of file diff --git a/Source/CDR.DataRecipient.E2ETests/e2e.runsettings b/Source/CDR.DataRecipient.E2ETests/e2e.runsettings deleted file mode 100644 index b3eef4e..0000000 --- a/Source/CDR.DataRecipient.E2ETests/e2e.runsettings +++ /dev/null @@ -1,7 +0,0 @@ - - - - - Release - - \ No newline at end of file diff --git a/Source/CDR.DataRecipient.IntegrationTests/CDR.DataRecipient.IntegrationTests.csproj b/Source/CDR.DataRecipient.IntegrationTests/CDR.DataRecipient.IntegrationTests.csproj index 2cd29e0..a0f31e0 100644 --- a/Source/CDR.DataRecipient.IntegrationTests/CDR.DataRecipient.IntegrationTests.csproj +++ b/Source/CDR.DataRecipient.IntegrationTests/CDR.DataRecipient.IntegrationTests.csproj @@ -5,11 +5,11 @@ false - 1.0.0 + 1.0.1 - 1.0.0 + 1.0.1 - 1.0.0 + 1.0.1 diff --git a/Source/CDR.DataRecipient.IntegrationTests/US12693_MDR_HostArrangementRevocation-JWT.cs b/Source/CDR.DataRecipient.IntegrationTests/US12693_MDR_HostArrangementRevocation-JWT.cs index 8e8b461..94c35f0 100644 --- a/Source/CDR.DataRecipient.IntegrationTests/US12693_MDR_HostArrangementRevocation-JWT.cs +++ b/Source/CDR.DataRecipient.IntegrationTests/US12693_MDR_HostArrangementRevocation-JWT.cs @@ -49,7 +49,7 @@ async Task GetAccessTokenFromRegister() JWT_CertificateFilename = JWT_CERTIFICATE_FILENAME, JWT_CertificatePassword = JWT_CERTIFICATE_PASSWORD, ClientId = SOFTWAREPRODUCT_ID, - Scope = "cdr-register:bank:read", + Scope = "cdr-register:bank:read cdr-register:read", ClientAssertionType = CLIENTASSERTIONTYPE, GrantType = "client_credentials", Issuer = SOFTWAREPRODUCT_ID, @@ -184,7 +184,10 @@ private static string CreateCdrArrangementJwt( string? sub = null, string? aud = null, string? jti = null, - int? expiryInSeconds = null) + int? expiryInSeconds = null, + string? kid = null, + string signingCertificateFileName = DATAHOLDER_CERTIFICATE_FILENAME, + string signingCertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD) { var claims = new Dictionary(); if (!string.IsNullOrEmpty(cdrArrangementId)) @@ -206,30 +209,27 @@ private static string CreateCdrArrangementJwt( claims.Add("exp", (DateTime.UtcNow.AddSeconds(expiryInSeconds.Value) - new DateTime(1970, 1, 1)).TotalSeconds); return JWT2.CreateJWT( - DATAHOLDER_CERTIFICATE_FILENAME, - DATAHOLDER_CERTIFICATE_PASSWORD, + signingCertificateFileName, + signingCertificatePassword, claims, - "7C5716553E9B132EF325C49CA2079737196C03DB"); + kid ?? "7C5716553E9B132EF325C49CA2079737196C03DB"); } - [Theory] - [InlineData(HttpStatusCode.NoContent)] - public async Task AC01_Post_WithCDRArrangementJWT_ShouldRespondWith_204NoContent(HttpStatusCode expectedStatusCode) + [Fact] + public async Task AC01_Post_WithCDRArrangementIdParameterOnly_ShouldRespondWith_400BadRequest_ErrorResponse() { // Arrange - var clientAssertion = GetClientAssertion(); var cdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, HttpMethod = HttpMethod.Post, URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = clientAssertion, + AccessToken = GetClientAssertion(), Content = new FormUrlEncodedContent(new List> { - new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + new KeyValuePair("cdr_arrangement_id", "123") }), ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), }; @@ -241,18 +241,24 @@ public async Task AC01_Post_WithCDRArrangementJWT_ShouldRespondWith_204NoContent using (new AssertionScope()) { // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var expectedContent = @"{ + ""errors"": [{ + ""code"": ""urn:au-cds:error:cds-all:Field/Missing"", + ""title"": ""Missing Required Field"", + ""detail"": ""cdr_arrangement_jwt"" + }] + }"; + await Assert_HasContent_Json(expectedContent, response.Content); } } - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("foo", HttpStatusCode.UnprocessableEntity)] - public async Task AC02_Post_WithInvalidCDRArrangementIdInJWT_ShouldRespondWith_422UnprocessableEntity_ErrorResponse(string? cdrArrangementId, HttpStatusCode expectedStatusCode) + [Fact] + public async Task AC02_Post_WithEmptyCDRArrangementJwtParameter_ShouldRespondWith_400BadRequest_ErrorResponse() { // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId ?? actualCdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); + var cdrArrangementId = await Arrange(); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -262,7 +268,7 @@ public async Task AC02_Post_WithInvalidCDRArrangementIdInJWT_ShouldRespondWith_4 AccessToken = GetClientAssertion(), Content = new FormUrlEncodedContent(new List> { - new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + new KeyValuePair("cdr_arrangement_jwt", "") }), ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), }; @@ -274,31 +280,24 @@ public async Task AC02_Post_WithInvalidCDRArrangementIdInJWT_ShouldRespondWith_4 using (new AssertionScope()) { // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ + var expectedContent = @"{ ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Authorisation/InvalidArrangement"", - ""title"": ""Invalid Consent Arrangement"", - ""detail"": ""Invalid arrangement: foo"" + ""code"": ""urn:au-cds:error:cds-all:Field/Missing"", + ""title"": ""Missing Required Field"", + ""detail"": ""cdr_arrangement_jwt"" }] }"; - await Assert_HasContent_Json(expectedContent, response.Content); - } + await Assert_HasContent_Json(expectedContent, response.Content); } } - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("foo", HttpStatusCode.UnprocessableEntity)] // Create arrangement beloning to DataHolderBrandId of "foo" - public async Task AC03_Post_WithCDRArrangementIdNotOwnedByDataholder_ShouldRespondWith_422UnprocessableEntity_ErrorResponse(string? dataHolderBrandId, HttpStatusCode expectedStatusCode) + [Fact] + public async Task AC03_Post_WithNullCDRArrangementJwtParameter_ShouldRespondWith_400BadRequest_ErrorResponse() { // Arrange - var cdrArrangementId = await Arrange(dataHolderBrandId); - var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); - + var cdrArrangementId = await Arrange(); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -308,7 +307,7 @@ public async Task AC03_Post_WithCDRArrangementIdNotOwnedByDataholder_ShouldRespo AccessToken = GetClientAssertion(), Content = new FormUrlEncodedContent(new List> { - new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + new KeyValuePair("cdr_arrangement_jwt", null) }), ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), }; @@ -320,26 +319,24 @@ public async Task AC03_Post_WithCDRArrangementIdNotOwnedByDataholder_ShouldRespo using (new AssertionScope()) { // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ + var expectedContent = @"{ ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Authorisation/InvalidArrangement"", - ""title"": ""Invalid Consent Arrangement"", - ""detail"": ""Invalid arrangement: #{cdrArrangementId}"" + ""code"": ""urn:au-cds:error:cds-all:Field/Missing"", + ""title"": ""Missing Required Field"", + ""detail"": ""cdr_arrangement_jwt"" }] - }".Replace("#{cdrArrangementId}", cdrArrangementId); - await Assert_HasContent_Json(expectedContent, response.Content); - } + }"; + await Assert_HasContent_Json(expectedContent, response.Content); } } [Fact] - public async Task AC04_Post_WithEmptyCDRArrangementJWTPayload_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC03b_Post_WithEmptyCDRArrangementJWTPayload_ShouldRespondWith_400BadRequest_ErrorResponse() { // Arrange + var cdrArrangementId = await Arrange(); var cdrArrangementJwt = CreateCdrArrangementJwt(); var apiCall = new Infrastructure.API { @@ -376,10 +373,11 @@ public async Task AC04_Post_WithEmptyCDRArrangementJWTPayload_ShouldRespondWith_ } [Fact] - public async Task AC05_Post_WithEmptyCDRArrangementJWTPayload_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC04_Post_WithCDRArrangementJwtWithNoCdrArrangementId_ShouldRespondWith_400BadRequest_ErrorResponse() { // Arrange - var cdrArrangementJwt = ""; + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(null, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -404,24 +402,22 @@ public async Task AC05_Post_WithEmptyCDRArrangementJWTPayload_ShouldRespondWith_ response.StatusCode.Should().Be(HttpStatusCode.BadRequest); var expectedContent = @"{ - ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Field/Missing"", - ""title"": ""Missing Required Field"", - ""detail"": ""cdr_arrangement_jwt or cdr_arrangement_id"" - }] - }"; + ""errors"": [{ + ""code"": ""urn:au-cds:error:cds-all:Field/Invalid"", + ""title"": ""Invalid Field"", + ""detail"": ""cdr_arrangement_jwt"" + }] + }"; await Assert_HasContent_Json(expectedContent, response.Content); } } - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("", HttpStatusCode.BadRequest)] - public async Task AC06_Post_WithEmptyCDRArrangementIdInJWT_ShouldRespondWith_400BadRequest_ErrorResponse(string? cdrArrangementId, HttpStatusCode expectedStatusCode) + [Fact] + public async Task AC04b_Post_WithCDRArrangementJwtWithEmptyCdrArrangementId_ShouldRespondWith_400BadRequest_ErrorResponse() { // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId ?? actualCdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt("", DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -443,28 +439,25 @@ public async Task AC06_Post_WithEmptyCDRArrangementIdInJWT_ShouldRespondWith_400 using (new AssertionScope()) { // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ + var expectedContent = @"{ ""errors"": [{ ""code"": ""urn:au-cds:error:cds-all:Field/Invalid"", ""title"": ""Invalid Field"", ""detail"": ""cdr_arrangement_jwt"" }] }"; - await Assert_HasContent_Json(expectedContent, response.Content); - } + await Assert_HasContent_Json(expectedContent, response.Content); } } [Fact] - public async Task AC07_Post_WithEmptyIssuerInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC05_Post_WithNonMatchingCDRArrangementIdValues_ShouldRespondWith_400BadRequest_ErrorResponse() { // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(actualCdrArrangementId, "", DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt("1", DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -474,7 +467,8 @@ public async Task AC07_Post_WithEmptyIssuerInJWT_ShouldRespondWith_400BadRequest AccessToken = GetClientAssertion(), Content = new FormUrlEncodedContent(new List> { - new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt), + new KeyValuePair("cdr_arrangement_id", "2") }), ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), }; @@ -492,7 +486,47 @@ public async Task AC07_Post_WithEmptyIssuerInJWT_ShouldRespondWith_400BadRequest ""errors"": [{ ""code"": ""urn:au-cds:error:cds-all:Field/Invalid"", ""title"": ""Invalid Field"", - ""detail"": ""cdr_arrangement_jwt"" + ""detail"": ""cdr_arrangement_id"" + }] + }"; + await Assert_HasContent_Json(expectedContent, response.Content); + } + } + + [Fact] + public async Task AC06_Post_WithInvalidCDRArrangementIdValue_ShouldRespondWith_422UnprocessableEntity_ErrorResponse() + { + // Arrange + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt("foo", DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); + var apiCall = new Infrastructure.API + { + CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, + CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, + HttpMethod = HttpMethod.Post, + URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, + AccessToken = GetClientAssertion(), + Content = new FormUrlEncodedContent(new List> + { + new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + }), + ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), + }; + + // Act + var response = await apiCall.SendAsync(); + + // Assert + using (new AssertionScope()) + { + // Assert - Check status code + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + + var expectedContent = @"{ + ""errors"": [{ + ""code"": ""urn:au-cds:error:cds-all:Authorisation/InvalidArrangement"", + ""title"": ""Invalid Consent Arrangement"", + ""detail"": ""foo"" }] }"; await Assert_HasContent_Json(expectedContent, response.Content); @@ -500,11 +534,93 @@ public async Task AC07_Post_WithEmptyIssuerInJWT_ShouldRespondWith_400BadRequest } [Fact] - public async Task AC08_Post_WithEmptySubInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC07_Post_WithUnownedCDRArrangementId_ShouldRespondWith_422UnprocessableEntity_ErrorResponse() + { + // Arrange + var cdrArrangementId = await Arrange(Guid.NewGuid().ToString()); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId); + var apiCall = new Infrastructure.API + { + CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, + CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, + HttpMethod = HttpMethod.Post, + URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, + AccessToken = GetClientAssertion(), + Content = new FormUrlEncodedContent(new List> + { + new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + }), + ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), + }; + + // Act + var response = await apiCall.SendAsync(); + + // Assert + using (new AssertionScope()) + { + // Assert - Check status code + response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + + var expectedContent = @"{ + ""errors"": [{ + ""code"": ""urn:au-cds:error:cds-all:Authorisation/InvalidArrangement"", + ""title"": ""Invalid Consent Arrangement"", + ""detail"": ""#{cdrArrangementId}"" + }] + }".Replace("#{cdrArrangementId}", cdrArrangementId); + await Assert_HasContent_Json(expectedContent, response.Content); + } + } + + // TODO: this is an interesting case. Having a non-matching kid (key id) does not break the validation. + // It appears like the MS library will validate the signature anyway with the key values in the JWKS even if the kid doesn't directly match. + //[Fact] + //public async Task AC08_Post_WithInvalidSignatureCDRArrangementJwt_ShouldRespondWith_400InvalidField_ErrorResponse() + //{ + // // Arrange + // var cdrArrangementId = await Arrange(); + // var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, kid: "foo"); + // var apiCall = new Infrastructure.API + // { + // CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, + // CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, + // HttpMethod = HttpMethod.Post, + // URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, + // AccessToken = GetClientAssertion(), + // Content = new FormUrlEncodedContent(new List> + // { + // new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + // }), + // ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), + // }; + + // // Act + // var response = await apiCall.SendAsync(); + + // // Assert + // using (new AssertionScope()) + // { + // // Assert - Check status code + // response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + // var expectedContent = @"{ + // ""errors"": [{ + // ""code"": ""urn:au-cds:error:cds-all:Field/Invalid"", + // ""title"": ""Invalid Field"", + // ""detail"": ""cdr_arrangement_jwt"" + // }] + // }"; + // await Assert_HasContent_Json(expectedContent, response.Content); + // } + //} + + [Fact] + public async Task AC08b_Post_WithInvalidSignatureCDRArrangementJwt_ShouldRespondWith_400InvalidField_ErrorResponse() { // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(actualCdrArrangementId, DATAHOLDER_BRAND.ToLower(), "", DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, signingCertificateFileName: JWT_CERTIFICATE_FILENAME, signingCertificatePassword: JWT_CERTIFICATE_PASSWORD); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -540,11 +656,129 @@ public async Task AC08_Post_WithEmptySubInJWT_ShouldRespondWith_400BadRequest_Er } [Fact] - public async Task AC09_Post_WithEmptyAudInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC09_Post_WithInvalidContentTypeHeader_ShouldRespondWith_400BadRequest_ErrorResponse() + { + // Arrange + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt("123"); + var apiCall = new Infrastructure.API + { + CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, + CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, + HttpMethod = HttpMethod.Post, + URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, + AccessToken = GetClientAssertion(), + Content = new FormUrlEncodedContent(new List> + { + new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + }), + ContentType = MediaTypeHeaderValue.Parse("application/json"), + }; + + // Act + var response = await apiCall.SendAsync(); + + // Assert + using (new AssertionScope()) + { + // Assert - Check status code + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var expectedContent = @"{ + ""errors"": [{ + ""code"": ""urn:au-cds:error:cds-all:Header/Invalid"", + ""title"": ""Invalid Header"", + ""detail"": """" + }] + }"; + await Assert_HasContent_Json(expectedContent, response.Content); + } + } + + [Fact] + public async Task AC10_Post_WithInvalidClientAssertion_ShouldRespondWith_401Unauthorized_ErrorResponse() + { + // Arrange + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId); + var apiCall = new Infrastructure.API + { + CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, + CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, + HttpMethod = HttpMethod.Post, + URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, + AccessToken = "foo" ?? GetClientAssertion(), + Content = new FormUrlEncodedContent(new List> + { + new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + }), + ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), + }; + + // Act + var response = await apiCall.SendAsync(); + + // Assert + using (new AssertionScope()) + { + // Assert - Check status code + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // Assert - Check error response + Assert_HasHeader(@"Bearer error=""invalid_token""", + response.Headers, + "WWW-Authenticate" + ); // true); // starts with + } + } + + [Fact] + public async Task AC11_Post_WithMinimalCDRArrangementJWT_ShouldRespondWith_204NoContent() + { + // Arrange + var clientAssertion = GetClientAssertion(); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId); + var apiCall = new Infrastructure.API + { + CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, + CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, + HttpMethod = HttpMethod.Post, + URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, + AccessToken = clientAssertion, + Content = new FormUrlEncodedContent(new List> + { + new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) + }), + ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), + }; + + // Act + var response = await apiCall.SendAsync(); + + // Assert + using (new AssertionScope()) + { + // Assert - Check status code + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + } + + // + // TODO: The tests below should kick in from 15/11/2022 + // + [Fact] + public async Task AC12_Post_WithInvalidClaimsValuesInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() { + if (DateTime.UtcNow < AttemptValidateCdrArrangementJwtFromDate()) + { + Assert.True(true, $"This test will not run until {AttemptValidateCdrArrangementJwtFromDate()}"); + return; + } + // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(actualCdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), "", Guid.NewGuid().ToString(), 300); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, "1", "1", "https://foo.com", "123", 0); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -580,11 +814,17 @@ public async Task AC09_Post_WithEmptyAudInJWT_ShouldRespondWith_400BadRequest_Er } [Fact] - public async Task AC10_Post_WithEmptyJtiInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC13_Post_WithUnmatchingIssAndSubInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() { + if (DateTime.UtcNow < AttemptValidateCdrArrangementJwtFromDate()) + { + Assert.True(true, $"This test will not run until {AttemptValidateCdrArrangementJwtFromDate()}"); + return; + } + // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(actualCdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, "", 300); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, "foo1", "foo2", "https://foo.com", "123", 0); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -620,11 +860,17 @@ public async Task AC10_Post_WithEmptyJtiInJWT_ShouldRespondWith_400BadRequest_Er } [Fact] - public async Task AC11_Post_WithEmptyExpInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC14_Post_WithExpiredJWT_ShouldRespondWith_400BadRequest_ErrorResponse() { + if (DateTime.UtcNow < AttemptValidateCdrArrangementJwtFromDate()) + { + Assert.True(true, $"This test will not run until {AttemptValidateCdrArrangementJwtFromDate()}"); + return; + } + // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(actualCdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), null); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), -300); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -660,11 +906,17 @@ public async Task AC11_Post_WithEmptyExpInJWT_ShouldRespondWith_400BadRequest_Er } [Fact] - public async Task AC12_Post_WithExpiredJWT_ShouldRespondWith_400BadRequest_ErrorResponse() + public async Task AC15_Post_WithInvalidAudienceInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() { + if (DateTime.UtcNow < AttemptValidateCdrArrangementJwtFromDate()) + { + Assert.True(true, $"This test will not run until {AttemptValidateCdrArrangementJwtFromDate()}"); + return; + } + // Arrange - var actualCdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(actualCdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), -300); + var cdrArrangementId = await Arrange(); + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), "https://foo.com", Guid.NewGuid().ToString(), 300); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -699,15 +951,18 @@ public async Task AC12_Post_WithExpiredJWT_ShouldRespondWith_400BadRequest_Error } } - [Theory] - [InlineData("application/x-www-form-urlencoded", HttpStatusCode.NoContent)] - [InlineData("application/json", HttpStatusCode.BadRequest)] // Invalid content type header - public async Task AC07_Post_WithInvalidContentTypeHeader_ShouldRespondWith_400BadRequest_ErrorResponse(string? contentTypeHeader, HttpStatusCode expectedStatusCode) + [Fact] + public async Task AC16_Post_WithInvalidIssuerInJWT_ShouldRespondWith_400BadRequest_ErrorResponse() { + if (DateTime.UtcNow < AttemptValidateCdrArrangementJwtFromDate()) + { + Assert.True(true, $"This test will not run until {AttemptValidateCdrArrangementJwtFromDate()}"); + return; + } + // Arrange var cdrArrangementId = await Arrange(); - var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, DATAHOLDER_BRAND.ToLower(), DATAHOLDER_BRAND.ToLower(), DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); - + var cdrArrangementJwt = CreateCdrArrangementJwt(cdrArrangementId, "foo", "foo", DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, Guid.NewGuid().ToString(), 300); var apiCall = new Infrastructure.API { CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, @@ -719,7 +974,7 @@ public async Task AC07_Post_WithInvalidContentTypeHeader_ShouldRespondWith_400Ba { new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) }), - ContentType = MediaTypeHeaderValue.Parse(contentTypeHeader), + ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), }; // Act @@ -729,26 +984,21 @@ public async Task AC07_Post_WithInvalidContentTypeHeader_ShouldRespondWith_400Ba using (new AssertionScope()) { // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ + var expectedContent = @"{ ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Header/Invalid"", - ""title"": ""Invalid Header"", - ""detail"": """" + ""code"": ""urn:au-cds:error:cds-all:Field/Invalid"", + ""title"": ""Invalid Field"", + ""detail"": ""cdr_arrangement_jwt"" }] }"; - await Assert_HasContent_Json(expectedContent, response.Content); - } + await Assert_HasContent_Json(expectedContent, response.Content); } } - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("foo", HttpStatusCode.Unauthorized)] - public async Task AC08_Post_WithInvalidBearerToken_ShouldRespondWith_401Unauthorised_ErrorResponse(string clientAssertion, HttpStatusCode expectedStatusCode) + [Fact] + public async Task AC17_Post_WithFullJwt_ShouldRespondWith_204NoContent() { // Arrange var cdrArrangementId = await Arrange(); @@ -759,7 +1009,7 @@ public async Task AC08_Post_WithInvalidBearerToken_ShouldRespondWith_401Unauthor CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, HttpMethod = HttpMethod.Post, URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = clientAssertion ?? GetClientAssertion(), + AccessToken = GetClientAssertion(), Content = new FormUrlEncodedContent(new List> { new KeyValuePair("cdr_arrangement_jwt", cdrArrangementJwt) @@ -774,18 +1024,20 @@ public async Task AC08_Post_WithInvalidBearerToken_ShouldRespondWith_401Unauthor using (new AssertionScope()) { // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + } - // Assert - Check error response - if (expectedStatusCode != HttpStatusCode.NoContent) - { - // Assert - Check error response - Assert_HasHeader(@"Bearer error=""invalid_token""", - response.Headers, - "WWW-Authenticate" - ); // true); // starts with - } + public static DateTime AttemptValidateCdrArrangementJwtFromDate() + { + var obligationDate = Configuration["AttemptValidateCdrArrangementJwtFromDate"]; + if (string.IsNullOrEmpty(obligationDate)) + { + return new DateTime(2022, 11, 15, 0, 0, 0, DateTimeKind.Utc); } + + return DateTime.Parse(obligationDate); } + } } diff --git a/Source/CDR.DataRecipient.IntegrationTests/US12693_MDR_HostArrangementRevocation.cs b/Source/CDR.DataRecipient.IntegrationTests/US12693_MDR_HostArrangementRevocation.cs deleted file mode 100644 index 43d679e..0000000 --- a/Source/CDR.DataRecipient.IntegrationTests/US12693_MDR_HostArrangementRevocation.cs +++ /dev/null @@ -1,431 +0,0 @@ -using CDR.DataRecipient.IntegrationTests.Fixtures; -using CDR.DataRecipient.IntegrationTests.Infrastructure.API2; -using CDR.DataRecipient.SDK.Models; -using FluentAssertions; -using FluentAssertions.Execution; -using Microsoft.Data.SqlClient; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Xunit; - -#nullable enable - -namespace CDR.DataRecipient.IntegrationTests -{ - public class US12693_MDR_HostArrangementRevocation : BaseTest - { - private class ConsentArrangement - { - public string? DataHolderBrandId { get; init; } - public string? ClientId { get; init; } - public int? SharingDuration { get; init; } - public string? CdrArrangementId { get; init; } - public string? IdToken { get; init; } - public string? AccessToken { get; init; } - public string? RefreshToken { get; init; } - public int? ExpiresIn { get; init; } - public string? Scope { get; init; } - public string? TokenType { get; init; } - public DateTime? CreatedOn { get; init; } - } - - private static async Task Arrange(string? dataHolderBrandId = null) - { - TestSetup.PatchRegister(); - - // Get access token for Register - async Task GetAccessTokenFromRegister() - { - var registerAccessToken = await new Infrastructure.AccessToken - { - URL = REGISTER_MTLS_TOKEN_URL, - CertificateFilename = CERTIFICATE_FILENAME, - CertificatePassword = CERTIFICATE_PASSWORD, - JWT_CertificateFilename = JWT_CERTIFICATE_FILENAME, - JWT_CertificatePassword = JWT_CERTIFICATE_PASSWORD, - ClientId = SOFTWAREPRODUCT_ID, - Scope = "cdr-register:bank:read", - ClientAssertionType = CLIENTASSERTIONTYPE, - GrantType = "client_credentials", - Issuer = SOFTWAREPRODUCT_ID, - Audience = REGISTER_MTLS_TOKEN_URL - }.GetAsync(); - - if (registerAccessToken == null) - { - throw new Exception("Error getting register access token"); - } - - return registerAccessToken; - } - - // Get data holder brands from register - async Task> GetDataHolderBrandsFromRegister(string token) - { - var apiCall = new Infrastructure.API - { - CertificateFilename = CERTIFICATE_FILENAME, - CertificatePassword = CERTIFICATE_PASSWORD, - HttpMethod = HttpMethod.Get, - URL = REGISTER_MTLS_DATAHOLDERBRANDS_URL, - AccessToken = token, - XV = "2" - }; - - var response = await apiCall.SendAsync(); - if (response.StatusCode != HttpStatusCode.OK) - { - throw new Exception($"{response.StatusCode} - {await response.Content.ReadAsStringAsync()}"); - } - - var json = await response.Content.ReadAsStringAsync(); - - var brandList = Newtonsoft.Json.JsonConvert.DeserializeObject>>(json)?.Data; - - return brandList ?? throw new Exception(); - } - - // Save data holder brands - void PersistDataHolderBrands(List brandList) - { - // Connect - using var connection = new SqlConnection(BaseTest.DATARECIPIENT_CONNECTIONSTRING); - connection.Open(); - - // Purge table - using var deleteCommand = new SqlCommand($"delete from dataholderbrand", connection); - deleteCommand.ExecuteNonQuery(); - - // Save each brand - foreach (var brand in brandList) - { - var jsonDocument = System.Text.Json.JsonSerializer.Serialize(brand); - - using var insertCommand = new SqlCommand($"insert into dataholderbrand (dataholderbrandid, jsondocument) values (@dataholderbrandid, @jsondocument)", connection); - insertCommand.Parameters.AddWithValue("@dataholderbrandid", brand.DataHolderBrandId); - insertCommand.Parameters.AddWithValue("@jsondocument", jsonDocument); - insertCommand.ExecuteNonQuery(); - } - - // Check count - using var selectCommand = new SqlCommand($"select count(*) from dataholderbrand", connection); - var count = Convert.ToInt32(selectCommand.ExecuteScalar()); - if (count != brandList.Count) - { - throw new Exception($"{nameof(PersistDataHolderBrands)} - Error persisting brands"); - } - } - - // Create and save a consent arrangement - string CreateConsentArrangement(string dataHolderBrandId, string clientId) - { - var consentArrangement = new ConsentArrangement - { - CdrArrangementId = Guid.NewGuid().ToString(), - DataHolderBrandId = dataHolderBrandId, - ClientId = clientId, - ExpiresIn = 300, - CreatedOn = DateTime.Now, - SharingDuration = 0, - }; - - // Connect - using var connection = new SqlConnection(BaseTest.DATARECIPIENT_CONNECTIONSTRING); - connection.Open(); - - // Purge table - using var deleteCommand = new SqlCommand($"delete from cdrarrangement", connection); - deleteCommand.ExecuteNonQuery(); - - // Save arrangement - var jsonDocument = System.Text.Json.JsonSerializer.Serialize(consentArrangement); - using var insertCommand = new SqlCommand($"insert into cdrarrangement (cdrarrangementid, clientid, jsondocument) values (@cdrarrangementid, @clientid, @jsondocument)", connection); - insertCommand.Parameters.AddWithValue("@cdrarrangementid", consentArrangement.CdrArrangementId); - insertCommand.Parameters.AddWithValue("@clientid", consentArrangement.ClientId); - insertCommand.Parameters.AddWithValue("@jsondocument", jsonDocument); - insertCommand.ExecuteNonQuery(); - - using var selectCommand = new SqlCommand($"select count(*) from cdrarrangement", connection); - var count = Convert.ToInt32(selectCommand.ExecuteScalar()); - if (count != 1) - { - throw new Exception($"{nameof(CreateConsentArrangement)} - Error creating consent arrangement"); - } - - return consentArrangement.CdrArrangementId; - } - - var registerToken = await GetAccessTokenFromRegister(); - var brandsList = await GetDataHolderBrandsFromRegister(registerToken); - PersistDataHolderBrands(brandsList); - return CreateConsentArrangement(dataHolderBrandId ?? DATAHOLDER_BRAND.ToLower(), SOFTWAREPRODUCT_ID); - } - - private static string GetClientAssertion() - { - return new ClientAssertion - { - CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, - CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, - Iss = DATAHOLDER_BRAND.ToLower(), - Aud = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - Kid = "7C5716553E9B132EF325C49CA2079737196C03DB", // Kid for this dataholder - }.Get(); - } - - [Theory] - [InlineData(HttpStatusCode.NoContent)] - public async Task AC01_Post_WithCDRArrangementId_ShouldRespondWith_204NoContent(HttpStatusCode expectedStatusCode) - { - // Arrange - var clientAssertion = GetClientAssertion(); - var cdrArrangementId = await Arrange(); - var apiCall = new Infrastructure.API - { - CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, - CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, - HttpMethod = HttpMethod.Post, - URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = clientAssertion, - Content = new FormUrlEncodedContent(new List> - { - new KeyValuePair("cdr_arrangement_id", cdrArrangementId) - }), - ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), - }; - - // Act - var response = await apiCall.SendAsync(); - - // Assert - using (new AssertionScope()) - { - // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); - } - } - - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("foo", HttpStatusCode.UnprocessableEntity)] - public async Task AC02_Post_WithInvalidCDRArrangementId_ShouldRespondWith_422UnprocessableEntity_ErrorResponse(string? cdrArrangementId, HttpStatusCode expectedStatusCode) - { - // Arrange - var actualCdrArrangementId = await Arrange(); - var apiCall = new Infrastructure.API - { - CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, - CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, - HttpMethod = HttpMethod.Post, - URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = GetClientAssertion(), - Content = new FormUrlEncodedContent(new List> - { - new KeyValuePair("cdr_arrangement_id", cdrArrangementId ?? actualCdrArrangementId) - }), - ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), - }; - - // Act - var response = await apiCall.SendAsync(); - - // Assert - using (new AssertionScope()) - { - // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); - - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ - ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Authorisation/InvalidArrangement"", - ""title"": ""Invalid Consent Arrangement"", - ""detail"": ""Invalid arrangement: foo"" - }] - }"; - await Assert_HasContent_Json(expectedContent, response.Content); - } - } - } - - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("foo", HttpStatusCode.UnprocessableEntity)] // Create arrangement beloning to DataHolderBrandId of "foo" - public async Task AC03_Post_WithCDRArrangementIdNotOwnedByDataholder_ShouldRespondWith_422UnprocessableEntity_ErrorResponse(string? dataHolderBrandId, HttpStatusCode expectedStatusCode) - { - // Arrange - var cdrArrangementId = await Arrange(dataHolderBrandId); - - var apiCall = new Infrastructure.API - { - CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, - CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, - HttpMethod = HttpMethod.Post, - URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = GetClientAssertion(), - Content = new FormUrlEncodedContent(new List> - { - new KeyValuePair("cdr_arrangement_id", cdrArrangementId) - }), - ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), - }; - - // Act - var response = await apiCall.SendAsync(); - - // Assert - using (new AssertionScope()) - { - // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); - - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ - ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Authorisation/InvalidArrangement"", - ""title"": ""Invalid Consent Arrangement"", - ""detail"": ""Invalid arrangement: #{cdrArrangementId}"" - }] - }".Replace("#{cdrArrangementId}", cdrArrangementId); - await Assert_HasContent_Json(expectedContent, response.Content); - } - } - } - - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("", HttpStatusCode.BadRequest)] - public async Task AC04_Post_WithEmptyCDRArrangementId_ShouldRespondWith_400BadRequest_ErrorResponse(string? cdrArrangementId, HttpStatusCode expectedStatusCode) - { - // Arrange - var actualCdrArrangementId = await Arrange(); - var apiCall = new Infrastructure.API - { - CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, - CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, - HttpMethod = HttpMethod.Post, - URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = GetClientAssertion(), - Content = new FormUrlEncodedContent(new List> - { - new KeyValuePair("cdr_arrangement_id", cdrArrangementId ?? actualCdrArrangementId) - }), - ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), - }; - - // Act - var response = await apiCall.SendAsync(); - - // Assert - using (new AssertionScope()) - { - // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); - - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ - ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Field/Missing"", - ""title"": ""Missing Required Field"", - ""detail"": ""cdr_arrangement_jwt or cdr_arrangement_id"" - }] - }"; - await Assert_HasContent_Json(expectedContent, response.Content); - } - } - } - - [Theory] - [InlineData("application/x-www-form-urlencoded", HttpStatusCode.NoContent)] - [InlineData("application/json", HttpStatusCode.BadRequest)] // Invalid content type header - public async Task AC05_Post_WithInvalidContentTypeHeader_ShouldRespondWith_400BadRequest_ErrorResponse(string? contentTypeHeader, HttpStatusCode expectedStatusCode) - { - // Arrange - var cdrArrangementId = await Arrange(); - var apiCall = new Infrastructure.API - { - CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, - CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, - HttpMethod = HttpMethod.Post, - URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = GetClientAssertion(), - Content = new FormUrlEncodedContent(new List> - { - new KeyValuePair("cdr_arrangement_id", cdrArrangementId) - }), - ContentType = MediaTypeHeaderValue.Parse(contentTypeHeader), - }; - - // Act - var response = await apiCall.SendAsync(); - - // Assert - using (new AssertionScope()) - { - // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); - - if (expectedStatusCode != HttpStatusCode.NoContent) - { - var expectedContent = @"{ - ""errors"": [{ - ""code"": ""urn:au-cds:error:cds-all:Header/Invalid"", - ""title"": ""Invalid Header"", - ""detail"": """" - }] - }"; - await Assert_HasContent_Json(expectedContent, response.Content); - } - } - } - - [Theory] - [InlineData(null, HttpStatusCode.NoContent)] - [InlineData("foo", HttpStatusCode.Unauthorized)] - public async Task AC06_Post_WithInvalidBearerToken_ShouldRespondWith_401Unauthorised_ErrorResponse(string clientAssertion, HttpStatusCode expectedStatusCode) - { - // Arrange - var cdrArrangementId = await Arrange(); - var apiCall = new Infrastructure.API - { - CertificateFilename = DATAHOLDER_CERTIFICATE_FILENAME, - CertificatePassword = DATAHOLDER_CERTIFICATE_PASSWORD, - HttpMethod = HttpMethod.Post, - URL = DATARECIPIENT_ARRANGEMENTS_REVOKE_URL, - AccessToken = clientAssertion ?? GetClientAssertion(), - Content = new FormUrlEncodedContent(new List> - { - new KeyValuePair("cdr_arrangement_id", cdrArrangementId) - }), - ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"), - }; - - // Act - var response = await apiCall.SendAsync(); - - // Assert - using (new AssertionScope()) - { - // Assert - Check status code - response.StatusCode.Should().Be(expectedStatusCode); - - // Assert - Check error response - if (expectedStatusCode != HttpStatusCode.NoContent) - { - // Assert - Check error response - Assert_HasHeader(@"Bearer error=""invalid_token""", - response.Headers, - "WWW-Authenticate" - ); // true); // starts with - } - } - } - } -} diff --git a/Source/CDR.DataRecipient.IntegrationTests/appsettings.json b/Source/CDR.DataRecipient.IntegrationTests/appsettings.json index 5e882d5..f75e6a8 100644 --- a/Source/CDR.DataRecipient.IntegrationTests/appsettings.json +++ b/Source/CDR.DataRecipient.IntegrationTests/appsettings.json @@ -8,6 +8,7 @@ "DataHolder": "localhost", "DataRecipient": "localhost" }, + "AttemptValidateCdrArrangementJwtFromDate": "2022-11-15T00:00:00", "Web_URL": "https://localhost:9001", "Logging": { "LogLevel": { diff --git a/Source/CDR.DataRecipient.Repository.SQL.UnitTests/CDR.DataRecipient.Repository.SQL.UnitTests.csproj b/Source/CDR.DataRecipient.Repository.SQL.UnitTests/CDR.DataRecipient.Repository.SQL.UnitTests.csproj index 6c3bec6..0a188fa 100644 --- a/Source/CDR.DataRecipient.Repository.SQL.UnitTests/CDR.DataRecipient.Repository.SQL.UnitTests.csproj +++ b/Source/CDR.DataRecipient.Repository.SQL.UnitTests/CDR.DataRecipient.Repository.SQL.UnitTests.csproj @@ -3,9 +3,9 @@ net6.0 false - 1.0.0 - 1.0.0 - 1.0.0 + 1.0.1 + 1.0.1 + 1.0.1 diff --git a/Source/CDR.DataRecipient.Repository.SQL/CDR.DataRecipient.Repository.SQL.csproj b/Source/CDR.DataRecipient.Repository.SQL/CDR.DataRecipient.Repository.SQL.csproj index 84ecd09..e9d2fa8 100644 --- a/Source/CDR.DataRecipient.Repository.SQL/CDR.DataRecipient.Repository.SQL.csproj +++ b/Source/CDR.DataRecipient.Repository.SQL/CDR.DataRecipient.Repository.SQL.csproj @@ -2,20 +2,20 @@ net6.0 - 1.0.0 - 1.0.0 - 1.0.0 + 1.0.1 + 1.0.1 + 1.0.1 - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/CDR.DataRecipient.Repository.SQL/SqlDataAccess.cs b/Source/CDR.DataRecipient.Repository.SQL/SqlDataAccess.cs index cc762e8..d501880 100644 --- a/Source/CDR.DataRecipient.Repository.SQL/SqlDataAccess.cs +++ b/Source/CDR.DataRecipient.Repository.SQL/SqlDataAccess.cs @@ -996,13 +996,17 @@ public async Task UpdateDcrMsgByDHBrandId(DcrMessage dcrMessage) { using (SqlConnection db = new(_dbConn)) { - db.Open(); - var sqlQuery = "UPDATE [DcrMessage] SET [MessageState] = @msgState, [MessageError] = @msgErr, [LastUpdated] = GETUTCDATE(), [ClientId] = @clientId WHERE [DataHolderBrandId] = @id"; + db.Open(); + + var sqlQuery = "UPDATE [DcrMessage] SET [BrandName] = @brandName, [InfosecBaseUri] = @infosecBaseUri, [MessageState] = @msgState, [MessageError] = @msgErr, [LastUpdated] = GETUTCDATE(), [ClientId] = @clientId WHERE [DataHolderBrandId] = @id"; + using var sqlCommand = new SqlCommand(sqlQuery, db); sqlCommand.Parameters.AddWithValue("@id", dcrMessage.DataHolderBrandId); sqlCommand.Parameters.AddWithValue("@msgState", dcrMessage.MessageState); sqlCommand.Parameters.AddWithValue("@msgErr", string.IsNullOrEmpty(dcrMessage.MessageError) ? DBNull.Value : dcrMessage.MessageError); sqlCommand.Parameters.AddWithValue("@clientId", string.IsNullOrEmpty(dcrMessage.ClientId) ? DBNull.Value : dcrMessage.ClientId); + sqlCommand.Parameters.AddWithValue("@brandName", string.IsNullOrEmpty(dcrMessage.BrandName) ? DBNull.Value : dcrMessage.BrandName); + sqlCommand.Parameters.AddWithValue("@infosecBaseUri", string.IsNullOrEmpty(dcrMessage.InfosecBaseUri) ? DBNull.Value : dcrMessage.InfosecBaseUri); await sqlCommand.ExecuteNonQueryAsync(); db.Close(); } @@ -1012,6 +1016,7 @@ public async Task UpdateDcrMsgByDHBrandId(DcrMessage dcrMessage) /// Update the DcrMessage MessageState and MessageError by the Queue MessageId /// /// The message object to be updated + /// msgState Abandoned brand name not available and can be ignored /// /// This is called from Azure Functions DCR /// @@ -1030,24 +1035,51 @@ public async Task UpdateDcrMsgByMessageId(DcrMessage dcrMessage) } } + /// + /// Update the DcrMessage MessageId (new added queue item id), MessageState and MessageError by the Queue MessageId + /// + /// The message object to be updated + /// This is called from Azure DCR Functions + /// BrandName not available when DCR + /// + public async Task UpdateDcrMsgReplaceMessageIdWithoutBrand(DcrMessage dcrMessage, string replacementMsgId = "") + { + using (SqlConnection db = new(_dbConn)) + { + db.Open(); + var sqlQuery = "UPDATE [DcrMessage] SET [MessageId] = @replaceMsgId, [MessageState] = @msgState, [MessageError] = @msgErr, [LastUpdated] = GETUTCDATE() WHERE [MessageId] = @id"; + using var sqlCommand = new SqlCommand(sqlQuery, db); + sqlCommand.Parameters.AddWithValue("@replaceMsgId", replacementMsgId); + sqlCommand.Parameters.AddWithValue("@msgState", dcrMessage.MessageState); + sqlCommand.Parameters.AddWithValue("@msgErr", string.IsNullOrEmpty(dcrMessage.MessageError) ? DBNull.Value : dcrMessage.MessageError); + sqlCommand.Parameters.AddWithValue("@id", dcrMessage.MessageId); + await sqlCommand.ExecuteNonQueryAsync(); + db.Close(); + } + } + + /// /// Update the DcrMessage MessageId (new added queue item id), MessageState and MessageError by the Queue MessageId /// /// The message object to be updated + /// Update BrandName for Discovery Data Holder /// - /// This is called from Azure DiscoverDataHolders and DCR Functions + /// This is called from Azure DiscoverDataHolders /// public async Task UpdateDcrMsgReplaceMessageId(DcrMessage dcrMessage, string replacementMsgId = "") { using (SqlConnection db = new(_dbConn)) { db.Open(); - var sqlQuery = "UPDATE [DcrMessage] SET [MessageId] = @replaceMsgId, [MessageState] = @msgState, [MessageError] = @msgErr, [LastUpdated] = GETUTCDATE() WHERE [MessageId] = @id"; + var sqlQuery = "UPDATE [DcrMessage] SET [MessageId] = @replaceMsgId, [BrandName] = @brandName, [InfosecBaseUri] = @infosecBaseUri, [MessageState] = @msgState, [MessageError] = @msgErr, [LastUpdated] = GETUTCDATE() WHERE [MessageId] = @id"; using var sqlCommand = new SqlCommand(sqlQuery, db); sqlCommand.Parameters.AddWithValue("@replaceMsgId", replacementMsgId); sqlCommand.Parameters.AddWithValue("@msgState", dcrMessage.MessageState); sqlCommand.Parameters.AddWithValue("@msgErr", string.IsNullOrEmpty(dcrMessage.MessageError) ? DBNull.Value : dcrMessage.MessageError); sqlCommand.Parameters.AddWithValue("@id", dcrMessage.MessageId); + sqlCommand.Parameters.AddWithValue("@brandName", string.IsNullOrEmpty(dcrMessage.BrandName) ? DBNull.Value : dcrMessage.BrandName); + sqlCommand.Parameters.AddWithValue("@infosecBaseUri", string.IsNullOrEmpty(dcrMessage.InfosecBaseUri) ? DBNull.Value : dcrMessage.InfosecBaseUri); await sqlCommand.ExecuteNonQueryAsync(); db.Close(); } diff --git a/Source/CDR.DataRecipient.SDK/CDR.DataRecipient.SDK.csproj b/Source/CDR.DataRecipient.SDK/CDR.DataRecipient.SDK.csproj index 416b034..c7fd0b4 100644 --- a/Source/CDR.DataRecipient.SDK/CDR.DataRecipient.SDK.csproj +++ b/Source/CDR.DataRecipient.SDK/CDR.DataRecipient.SDK.csproj @@ -2,9 +2,9 @@ net6.0 - 1.0.0 - 1.0.0 - 1.0.0 + 1.0.1 + 1.0.1 + 1.0.1 diff --git a/Source/CDR.DataRecipient.SDK/Extensions/TokenExtensions.cs b/Source/CDR.DataRecipient.SDK/Extensions/TokenExtensions.cs index 8573fc7..0cfc8a1 100644 --- a/Source/CDR.DataRecipient.SDK/Extensions/TokenExtensions.cs +++ b/Source/CDR.DataRecipient.SDK/Extensions/TokenExtensions.cs @@ -1,4 +1,5 @@ using Jose; +using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using System; using System.Collections.Generic; @@ -16,6 +17,7 @@ public static class TokenExtensions public static async Task<(bool IsValid, JwtSecurityToken ValidatedToken, ClaimsPrincipal ClaimsPrincipal)> ValidateToken( this string jwt, string jwksUri, + ILogger logger, string validIssuer = null, string[] validAudiences = null, int clockSkewMins = 2, @@ -26,9 +28,12 @@ public static class TokenExtensions var jwks = await jwksUri.GetJwks(acceptAnyServerCertificate, enforceHttpsEndpoint); if (jwks == null || jwks.Keys.Count == 0) { + logger.LogDebug("Keys not found in JWKS: {jwksUri}", jwksUri); return (false, null, null); } + logger.LogDebug("Keys found in JWKS: {keys}", string.Join(',', jwks.Keys.Select(k => k.Kid).ToArray())); + var tokenValidationParameters = new TokenValidationParameters { ClockSkew = TimeSpan.FromMinutes(clockSkewMins), @@ -37,13 +42,14 @@ public static class TokenExtensions ValidIssuer = validIssuer, ValidateIssuer = !string.IsNullOrEmpty(validIssuer), ValidAudiences = validAudiences, - ValidateAudience = (validAudiences != null && validAudiences.Any()), + ValidateAudience = validAudiences != null && validAudiences.Any(), RequireSignedTokens = true, ValidateLifetime = validateLifetime, }; try { + logger.LogDebug("Validating token: {jwt}", jwt); var handler = new JwtSecurityTokenHandler(); var claimsPrincipal = handler.ValidateToken(jwt, tokenValidationParameters, out var token); return (true, token as JwtSecurityToken, claimsPrincipal); @@ -51,6 +57,7 @@ public static class TokenExtensions catch (Exception ex) { // implement logging. + logger.LogError(ex, "An error occurred validating the JWT"); } return (false, null, null); diff --git a/Source/CDR.DataRecipient.Web/CDR.DataRecipient.Web.csproj b/Source/CDR.DataRecipient.Web/CDR.DataRecipient.Web.csproj index 9e4a6cd..56cb992 100644 --- a/Source/CDR.DataRecipient.Web/CDR.DataRecipient.Web.csproj +++ b/Source/CDR.DataRecipient.Web/CDR.DataRecipient.Web.csproj @@ -5,9 +5,9 @@ win-x64;linux-x64 e18cd40c-c7d1-4df4-8448-82de536a628c Linux - 1.0.0 - 1.0.0 - 1.0.0 + 1.0.1 + 1.0.1 + 1.0.1 @@ -22,18 +22,18 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + @@ -43,7 +43,7 @@ - + diff --git a/Source/CDR.DataRecipient.Web/Common/Constants.cs b/Source/CDR.DataRecipient.Web/Common/Constants.cs index dbb208d..5310459 100644 --- a/Source/CDR.DataRecipient.Web/Common/Constants.cs +++ b/Source/CDR.DataRecipient.Web/Common/Constants.cs @@ -8,7 +8,8 @@ public static class MockDataRecipient { public const string Hostname = "MockDataRecipient:Hostname"; public const string DefaultPageSize = "MockDataRecipient:Paging:DefaultPageSize"; - public const string CdrArrangementAsJwtOnly = "MockDataRecipient:Arrangement:UseJwtOnly"; + public const string AttemptValidateCdrArrangementJwtFromDate = "MockDataRecipient:Arrangement:AttemptValidateJwtFromDate"; + public static class SoftwareProduct { @@ -69,6 +70,7 @@ public static class Content public static class Claims { + public const string ClientId = "client_id"; public const string UserId = "userId"; public const string Name = "name"; } @@ -103,6 +105,10 @@ public static class CdrArrangementRevocationRequest public const string CdrArrangementJwt = "cdr_arrangement_jwt"; } + public static class Defaults + { + public const string DefaultUserName = "unknown"; + } public const string DEFAULT_KEY_ID = "7EFA85C18FDE857949BC2EAA21C25E49627D4865"; public const string DEFAULT_PRIVATE_KEY = diff --git a/Source/CDR.DataRecipient.Web/Controllers/RevocationController.cs b/Source/CDR.DataRecipient.Web/Controllers/RevocationController.cs index 2eb06b4..ae9e057 100644 --- a/Source/CDR.DataRecipient.Web/Controllers/RevocationController.cs +++ b/Source/CDR.DataRecipient.Web/Controllers/RevocationController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using System; using System.ComponentModel.DataAnnotations; using System.IdentityModel.Tokens.Jwt; using System.Linq; @@ -29,14 +30,14 @@ public class RevocationController : Controller public RevocationController( IConfiguration config, - IDataHolderDiscoveryCache dataHolderDiscoveryCache, + IDataHolderDiscoveryCache dataHolderDiscoveryCache, IConsentsRepository consentsRepository, ILogger logger) { _config = config; - _dataHolderDiscoveryCache = dataHolderDiscoveryCache; + _dataHolderDiscoveryCache = dataHolderDiscoveryCache; _consentsRepository = consentsRepository; - _logger = logger; + _logger = logger; } [HttpPost] @@ -47,85 +48,120 @@ public async Task Revoke([Required, FromForm] RevocationModel rev { _logger.LogDebug("Revocation request received. cdr_arrangement_id: {CdrArrangementId}. cdr_arrangement_jwt: {CdrArrangementJwt}", revocationModel.CdrArrangementId, revocationModel.CdrArrangementJwt); - // When the Data Holder sends an arrangement revoke request to the Data Recipient, - // From March 31st 2022, Data Recipients MUST support "CDR Arrangement JWT" method. - // Until July 31st 2022, Data Recipients MUST support both "CDR Arrangement Form Parameter" method and "CDR Arrangement JWT". - // From July 31st 2022, Data Recipients MUST only support "CDR Arrangement JWT" method and MUST reject "CDR Arrangement Form Parameter" method. - // If only accepting JWT parameter and it has not been supplied. - if (_config.CdrArrangementAsJwtOnly() && string.IsNullOrEmpty(revocationModel.CdrArrangementJwt)) + // When the Data Holder sends an arrangement revoke request to the Data Recipient: + // From 31 July 2022: + // - Mock Data Recipient(MDR) must validate that the cdr_arrangement_jwt form parameter is passed in the request and includes a cdr_arrangement_id field. + // - MDR cannot reject a request that contains a cdr_arrangement_id form parameter. + // - If both the cdr_arrangement_id and cdr_arrangement_jwt form parameters are passed by the data holder, then the MDR must validate that both cdr_arrangement_id values match + // From 15 November 2022: + // - if the Self-Signed JWT claims (iss, sub, exp, aud, jti) are presented in the cdr_arrangement_jwt form parameter, MDR must validate in accordance with "Data Holders calling Data Recipients using Self-Signed JWT Client Authentication"(https://consumerdatastandardsaustralia.github.io/standards/#client-authentication) + + // Validate that the cdr arrangement jwt parameter has been passed. + if (string.IsNullOrEmpty(revocationModel.CdrArrangementJwt)) { + _logger.LogDebug("The cdr_arrangement_jwt was missing"); return BadRequest(new ErrorListModel(ErrorCodes.MissingField, ErrorTitles.MissingField, CdrArrangementRevocationRequest.CdrArrangementJwt)); } - // At least 1 field needs to be provided. - if (string.IsNullOrEmpty(revocationModel.CdrArrangementId) && string.IsNullOrEmpty(revocationModel.CdrArrangementJwt)) + // Retrieve the cdr_arrangement_id from the JWT. + var handler = new JwtSecurityTokenHandler(); + var token = handler.ReadJwtToken(revocationModel.CdrArrangementJwt); + + if (token == null || token.Claims == null || !token.Claims.Any()) { - return BadRequest(new ErrorListModel(ErrorCodes.MissingField, ErrorTitles.MissingField, $"{CdrArrangementRevocationRequest.CdrArrangementJwt} or {CdrArrangementRevocationRequest.CdrArrangementId}")); + _logger.LogDebug("The cdr_arrangement_jwt did not have claims"); + return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementJwt)); } - // cdr_arrangement_jwt takes precedence. - if (!string.IsNullOrEmpty(revocationModel.CdrArrangementJwt)) + // cdr_arrangement_id claim was not found in cdr_arrangement_jwt. + var cdrArrangementIdClaim = token.Claims.FirstOrDefault(c => c.Type.Equals(CdrArrangementRevocationRequest.CdrArrangementId)); + if (cdrArrangementIdClaim == null) { - var sp = _config.GetSoftwareProductConfig(); - - // Retrieve the cdr_arrangement_id from the JWT. - var handler = new JwtSecurityTokenHandler(); - var token = handler.ReadJwtToken(revocationModel.CdrArrangementJwt); + _logger.LogDebug("The cdr_arrangement_jwt did not contain a cdr_arrangement_id claim"); + return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementJwt)); + } - if (token == null || token.Claims == null || !token.Claims.Any()) - { - return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementJwt)); - } + // If a cdr_arrangement_id form parameter has also been passed, then validate the value is the same as the value in the cdr_arrangement_jwt. + if (!string.IsNullOrEmpty(revocationModel.CdrArrangementId) + && !revocationModel.CdrArrangementId.Equals(cdrArrangementIdClaim.Value, System.StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("The provided cdr_arrangement_id values did not match"); + return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementId)); + } - var cdrArrangementIdClaim = token.Claims.FirstOrDefault(c => c.Type.Equals(CdrArrangementRevocationRequest.CdrArrangementId)); - if (cdrArrangementIdClaim == null) - { - return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementJwt)); - } + var sp = _config.GetSoftwareProductConfig(); - // Check for mandatory claims in the cdr_arrangement_jwt. - var issClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("iss")); - var subClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("sub")); - var audClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("aud")); - var jtiClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("jti")); - var expClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("exp")); - if (subClaim == null || string.IsNullOrEmpty(subClaim.Value) - || issClaim == null || string.IsNullOrEmpty(issClaim.Value) - || audClaim == null || string.IsNullOrEmpty(audClaim.Value) - || jtiClaim == null || string.IsNullOrEmpty(jtiClaim.Value) - || expClaim == null || string.IsNullOrEmpty(expClaim.Value) - || !subClaim.Value.Equals(issClaim.Value)) - { - return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementJwt)); - } + // Find the matching cdr arrangement. + var arrangement = await _consentsRepository.GetConsentByArrangement(cdrArrangementIdClaim.Value); - // Find the matching cdr arrangement. - var arrangement = await _consentsRepository.GetConsentByArrangement(cdrArrangementIdClaim.Value); - if (arrangement == null - || !arrangement.DataHolderBrandId.Equals(issClaim.Value, System.StringComparison.OrdinalIgnoreCase)) - { - return UnprocessableEntity(new ErrorListModel(ErrorCodes.InvalidConsent, ErrorTitles.InvalidArrangement, $"Invalid arrangement: {cdrArrangementIdClaim.Value}")); - } + // If the arrangement was not found or if the arrangement does not belong to the calling data holder, then return an error. + // Note: The client_id in the bearer token contains the Data Holder Brand Id. + if (arrangement == null + || !arrangement.DataHolderBrandId.Equals(ClientBrandId, System.StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("The arrangement could not be found or was not owned by the calling data holder brand. Arrangement: {cdrArrangementId}, Data Holder Brand: {clientBrandId}", cdrArrangementIdClaim.Value, ClientBrandId); + return UnprocessableEntity(new ErrorListModel(ErrorCodes.InvalidConsent, ErrorTitles.InvalidArrangement, cdrArrangementIdClaim.Value)); + } - // Validate the cdr_arrangement_jwt using the brand id associated with the arrangement. - var jwksUri = await GetJwksUri(); - var validated = await revocationModel.CdrArrangementJwt.ValidateToken( - jwksUri, - validIssuer: arrangement.DataHolderBrandId, // DH Brand Id - validAudiences: new[] { sp.RevocationUri }, - acceptAnyServerCertificate: _config.IsAcceptingAnyServerCertificate()); + // Try and extract the "Self-Signed JWT Client Authentication" claims from the cdr_arrangement_jwt. + var issClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("iss")); + var subClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("sub")); + var audClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("aud")); + var jtiClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("jti")); + var expClaim = token.Claims.FirstOrDefault(c => c.Type.Equals("exp")); + + // From 15/11/2022, full cdr_arrangement_jwt is required is self-signed jwt parameters are included. + string validIssuer = null; + string validAudience = null; + bool validateLifetime = false; + bool fullValidationRequired = + DateTime.UtcNow > _config.AttemptValidateCdrArrangementJwtFromDate() + && HasValue(issClaim) + && HasValue(subClaim) + && HasValue(audClaim) + && HasValue(jtiClaim) + && HasValue(expClaim); + + if (fullValidationRequired) + { + _logger.LogDebug("Full validation of the cdr_arrangement_jwt should occur..."); - if (!validated.IsValid) + // iss claim and sub claim should have the same value. + if (!issClaim.Value.Equals(subClaim.Value, System.StringComparison.OrdinalIgnoreCase)) { + _logger.LogDebug("The iss and sub claim values did not match in the cdr_arrangement_jwt"); return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementJwt)); } - revocationModel.CdrArrangementId = cdrArrangementIdClaim.Value; + // Set the values that will be used in cdr_arrangement_jwt token validation. + validIssuer = ClientBrandId; + validAudience = sp.RevocationUri; + validateLifetime = true; + } + + // Validate the cdr_arrangement_jwt either using "full" or "minimal" validation. + var jwksUri = await GetJwksUri(); + var validated = await revocationModel.CdrArrangementJwt.ValidateToken( + jwksUri, + _logger, + validIssuer: validIssuer, + validAudiences: validAudience != null ? new string[] { validAudience } : null, + validateLifetime: validateLifetime, + acceptAnyServerCertificate: _config.IsAcceptingAnyServerCertificate()); + + if (!validated.IsValid) + { + _logger.LogDebug("Token validation failed on cdr_arrangement_jwt"); + return BadRequest(new ErrorListModel(ErrorCodes.InvalidField, ErrorTitles.InvalidField, CdrArrangementRevocationRequest.CdrArrangementJwt)); } + revocationModel.CdrArrangementId = cdrArrangementIdClaim.Value; + var isDeleted = await _consentsRepository.RevokeConsent(revocationModel.CdrArrangementId, ClientBrandId); if (!isDeleted) { + _logger.LogDebug("An error occurred when attempting to delete the arrangement"); + // No matching record in the DB for the arrangement id and brand id combination or failed to delete the consent return UnprocessableEntity(new ErrorListModel(ErrorCodes.InvalidConsent, ErrorTitles.InvalidArrangement, $"Invalid arrangement: {revocationModel.CdrArrangementId}")); } @@ -133,6 +169,11 @@ public async Task Revoke([Required, FromForm] RevocationModel rev return NoContent(); } + private static bool HasValue(Claim claim) + { + return claim != null && claim.Value != null; + } + private async Task GetJwksUri() { // Get the current data holder details. diff --git a/Source/CDR.DataRecipient.Web/Extensions.cs b/Source/CDR.DataRecipient.Web/Extensions.cs index 2843c08..4d5150a 100644 --- a/Source/CDR.DataRecipient.Web/Extensions.cs +++ b/Source/CDR.DataRecipient.Web/Extensions.cs @@ -1,6 +1,7 @@ using CDR.DataRecipient.SDK.Models; using CDR.DataRecipient.Web.Common; using Microsoft.Extensions.Configuration; +using System; namespace CDR.DataRecipient.Web.Extensions { @@ -51,9 +52,16 @@ public static DataHolderEndpoints GetDefaultDataHolderConfig(this IConfiguration return null; } - public static bool CdrArrangementAsJwtOnly(this IConfiguration config) + public static DateTime AttemptValidateCdrArrangementJwtFromDate(this IConfiguration config) { - return config.GetValue(Constants.ConfigurationKeys.MockDataRecipient.CdrArrangementAsJwtOnly, true); + var obligationDate = config.GetValue(Constants.ConfigurationKeys.MockDataRecipient.AttemptValidateCdrArrangementJwtFromDate); + if (string.IsNullOrEmpty(obligationDate)) + { + return new DateTime(2022, 11, 15, 0, 0, 0, DateTimeKind.Utc); + } + + return DateTime.Parse(obligationDate); } + } } \ No newline at end of file diff --git a/Source/CDR.DataRecipient.Web/Extensions/ClaimsPrincipalExtensions.cs b/Source/CDR.DataRecipient.Web/Extensions/ClaimsPrincipalExtensions.cs index 3339c5f..230742c 100644 --- a/Source/CDR.DataRecipient.Web/Extensions/ClaimsPrincipalExtensions.cs +++ b/Source/CDR.DataRecipient.Web/Extensions/ClaimsPrincipalExtensions.cs @@ -2,6 +2,7 @@ using CDR.DataRecipient.Web.Common; using System.Linq; using System.Security.Claims; +using static CDR.DataRecipient.Web.Common.Constants; namespace CDR.DataRecipient.Web.Extensions { @@ -31,5 +32,10 @@ public static bool IsLocal(this ClaimsPrincipal user) { return user.Identity.IsAuthenticated && user.Identity.AuthenticationType != null && user.Identity.AuthenticationType.Equals(Constants.LocalAuthentication.AuthenticationType); } + + public static bool IsUserNameUnknown(this ClaimsPrincipal user) + { + return user.GetUserName().ToLower().CompareTo(Defaults.DefaultUserName) == 0; + } } } \ No newline at end of file diff --git a/Source/CDR.DataRecipient.Web/Middleware/ClientAuthorizationMiddleware.cs b/Source/CDR.DataRecipient.Web/Middleware/ClientAuthorizationMiddleware.cs index a137747..288fb19 100644 --- a/Source/CDR.DataRecipient.Web/Middleware/ClientAuthorizationMiddleware.cs +++ b/Source/CDR.DataRecipient.Web/Middleware/ClientAuthorizationMiddleware.cs @@ -90,6 +90,7 @@ public async Task ValidateTokenAsync(string token) // Validate the token var validated = await token.ValidateToken( dataholderDiscoveryDocument.JwksUri, + _logger, tokenJwt.Issuer, new[] { _softwareProduct.RevocationUri, _softwareProduct.RecipientBaseUri }, validateLifetime: false); diff --git a/Source/CDR.DataRecipient.Web/Views/Consent/Consents.cshtml b/Source/CDR.DataRecipient.Web/Views/Consent/Consents.cshtml index ae3bd9d..6d79b83 100644 --- a/Source/CDR.DataRecipient.Web/Views/Consent/Consents.cshtml +++ b/Source/CDR.DataRecipient.Web/Views/Consent/Consents.cshtml @@ -8,7 +8,7 @@ @inject IConfiguration _config @model ConsentsModel -@if (string.IsNullOrEmpty(name)) +@if (string.IsNullOrEmpty(name) || Context.User.IsUserNameUnknown()) {

Consents

} diff --git a/Source/CDR.DataRecipient.Web/Views/Consent/Index.cshtml b/Source/CDR.DataRecipient.Web/Views/Consent/Index.cshtml index 682ec0c..eae6e52 100644 --- a/Source/CDR.DataRecipient.Web/Views/Consent/Index.cshtml +++ b/Source/CDR.DataRecipient.Web/Views/Consent/Index.cshtml @@ -66,7 +66,7 @@
- +
diff --git a/Source/CDR.DataRecipient.Web/Views/DynamicClientRegistration/Index.cshtml b/Source/CDR.DataRecipient.Web/Views/DynamicClientRegistration/Index.cshtml index 499e8b6..e7d0d4f 100644 --- a/Source/CDR.DataRecipient.Web/Views/DynamicClientRegistration/Index.cshtml +++ b/Source/CDR.DataRecipient.Web/Views/DynamicClientRegistration/Index.cshtml @@ -1,6 +1,13 @@ @{ ViewData["Title"] = "Dynamic Client Registration"; - var allowDynamicClientRegistration = await _featureManager.IsEnabledAsync(nameof(FeatureFlags.AllowDynamicClientRegistration)); + var allowDynamicClientRegistration = await _featureManager.IsEnabledAsync(nameof(FeatureFlags.AllowDynamicClientRegistration)); + + var mdrDisplayName = "Mock Data Recipient"; + + @if (!Context.User.IsLocal()) + { + mdrDisplayName = "Sandbox Data Recipient"; + } } @using CDR.DataRecipient.SDK.Enumerations @using CDR.DataRecipient.SDK.Extensions @@ -20,14 +27,17 @@

The page allows you to perform Dynamic Client Registration (DCR) with a Data Holder Brand that is currently stored in the Mock Data Recipient's memory - see the Discover Data Holders page for more detail.

} else +{ +

This page shows Data Holders for which this @(mdrDisplayName) has attempted a Dynamic Client Registration flow and the last response received.

+

This @(mdrDisplayName) will periodically attempt to perform Dynamic Client Registration with new Data Holder Brand Ids, or retry Dynamic Client Registration with Data Holder Brand Ids that were previously unsuccessful.

+} +@if (Context.User.IsLocal()) { -

This page shows Data Holders for which this Mock Data Recipient has attempted a Dynamic Client Registration flow and the last response received.

-

This Mock Data Recipient will periodically attempt to perform Dynamic Client Registration with new Data Holder Brand Ids, or retry Dynamic Client Registration with Data Holder Brand Ids that were previously unsuccessful.

+

If you would like to DCR to a CDR Mock Data Holder, firstly ensure the relevant solution is running then select either Mock Data Holder (Banking) or Mock Data Holder (Energy) from the 'DH Brand Name' dropdown.

+

Alternatively, you can select your own data holder solution if you have added it to the Mock Register and have refreshed the data holders held in memory by the Mock Data Recipient.

+

Note: The dummy data holder brands displayed here are for other testing purposes and they should not be used.

} -

If you would like to DCR to a CDR Mock Data Holder, firstly ensure the relevant solution is running then select either Mock Data Holder (Banking) or Mock Data Holder (Energy) from the 'DH Brand Name' dropdown.

-

Alternatively, you can select your own data holder solution if you have added it to the Mock Register and have refreshed the data holders held in memory by the Mock Data Recipient.

-

Note: The dummy data holder brands displayed here are for other testing purposes and they should not be used.


@@ -232,7 +242,7 @@ else @if (!allowDynamicClientRegistration) { @reg.MessageState - @reg.LastUpdated + @reg.LastUpdated.ToString("yyyy-MM-dd HH:mm:ss") } @if (allowDynamicClientRegistration) diff --git a/Source/CDR.DataRecipient.Web/Views/Home/Index.cshtml b/Source/CDR.DataRecipient.Web/Views/Home/Index.cshtml index 029fe0a..b4a85c9 100644 --- a/Source/CDR.DataRecipient.Web/Views/Home/Index.cshtml +++ b/Source/CDR.DataRecipient.Web/Views/Home/Index.cshtml @@ -9,7 +9,7 @@ @using Microsoft.Extensions.Configuration @inject IConfiguration _config -@if (Context.User.IsLocal()) +@if (Context.User.IsLocal() || Context.User.IsUserNameUnknown()) {

Welcome to the @(appName)

} diff --git a/Source/CDR.DataRecipient.Web/Views/Shared/_Layout.cshtml b/Source/CDR.DataRecipient.Web/Views/Shared/_Layout.cshtml index fe2e2bb..6e72396 100644 --- a/Source/CDR.DataRecipient.Web/Views/Shared/_Layout.cshtml +++ b/Source/CDR.DataRecipient.Web/Views/Shared/_Layout.cshtml @@ -2,7 +2,7 @@ var allowDynamicClientRegistration = await _featureManager.IsEnabledAsync(nameof(FeatureFlags.AllowDynamicClientRegistration)); var allowDataHolderRefresh = await _featureManager.IsEnabledAsync(nameof(FeatureFlags.AllowDataHolderRefresh)); var showSettings = await _featureManager.IsEnabledAsync(nameof(FeatureFlags.ShowSettings)); - var appName = _config.GetValue(Constants.Content.ApplicationName); + var appName = _config.GetValue(Constants.Content.ApplicationName); } @using CDR.DataRecipient.Web.Common @using CDR.DataRecipient.Web.Extensions @@ -36,7 +36,7 @@ @@ -59,49 +59,53 @@ @(allowDataHolderRefresh ? "Discover Data Holders" : "Data Holders") -
  • - - Get SSA - -
  • @(allowDynamicClientRegistration ? "Dynamic Client Registration" : "Dynamic Client Registrations")
  • - - Consent and Authorisation - -
  • -
  • - - Consents - -
  • -
  • - - Consumer Data Sharing - Common - -
  • -
  • - - Consumer Data Sharing - Banking - -
  • -
  • - - Consumer Data Sharing - Energy - + Consent +
  • - - PAR - + Consumer Data Sharing +
  • Utilities -
  • @@ -131,20 +140,20 @@ -
    -
    - @if (!string.IsNullOrEmpty(ViewBag.FooterContent)) - { - @Html.Raw(@ViewBag.FooterContent) - } - else - { - - © @DateTime.Now.Year - @(appName) - - } -
    -
    +
    +
    + @if (!string.IsNullOrEmpty(ViewBag.FooterContent)) + { + @Html.Raw(@ViewBag.FooterContent) + } + else + { + + © @DateTime.Now.Year - @(appName) + + } +
    +
    diff --git a/Source/CDR.DataRecipient.Web/appsettings.Development.json b/Source/CDR.DataRecipient.Web/appsettings.Development.json index 2472f80..407f2b9 100644 --- a/Source/CDR.DataRecipient.Web/appsettings.Development.json +++ b/Source/CDR.DataRecipient.Web/appsettings.Development.json @@ -15,6 +15,9 @@ "jwksUri": "https://localhost:9001/jwks", "redirectUris": "https://localhost:9001/consent/callback", "recipientBaseUri": "https://localhost:9001" + }, + "Arrangement": { + "AttemptValidateJwtFromDate": "2022-07-31T00:00:00" } }, "Serilog": { diff --git a/Source/CDR.DataRecipient.Web/appsettings.json b/Source/CDR.DataRecipient.Web/appsettings.json index 696a51b..4a523e4 100644 --- a/Source/CDR.DataRecipient.Web/appsettings.json +++ b/Source/CDR.DataRecipient.Web/appsettings.json @@ -44,7 +44,7 @@ "DefaultPageSize": 1000 }, "Arrangement": { - "UseJwtOnly": false + "AttemptValidateJwtFromDate": "2022-11-15T00:00:00" } }, "ConsumerDataStandardsSwaggerCommon": "https://consumerdatastandardsaustralia.github.io/standards/includes/swagger/cds_common.json", diff --git a/Source/CDR.DataRecipient/CDR.DataRecipient.csproj b/Source/CDR.DataRecipient/CDR.DataRecipient.csproj index 9c7acbc..6b1909e 100644 --- a/Source/CDR.DataRecipient/CDR.DataRecipient.csproj +++ b/Source/CDR.DataRecipient/CDR.DataRecipient.csproj @@ -2,9 +2,9 @@ net6.0 - 1.0.0 - 1.0.0 - 1.0.0 + 1.0.1 + 1.0.1 + 1.0.1 diff --git a/Source/CDR.DiscoverDataHolders/CDR.DiscoverDataHolders.csproj b/Source/CDR.DiscoverDataHolders/CDR.DiscoverDataHolders.csproj index 7e3e6c4..36f5a65 100644 --- a/Source/CDR.DiscoverDataHolders/CDR.DiscoverDataHolders.csproj +++ b/Source/CDR.DiscoverDataHolders/CDR.DiscoverDataHolders.csproj @@ -5,7 +5,7 @@ <_FunctionsSkipCleanOutput>true - + diff --git a/Source/CDR.DiscoverDataHolders/DiscoverDataHolders.cs b/Source/CDR.DiscoverDataHolders/DiscoverDataHolders.cs index dded3e2..3e8fc63 100644 --- a/Source/CDR.DiscoverDataHolders/DiscoverDataHolders.cs +++ b/Source/CDR.DiscoverDataHolders/DiscoverDataHolders.cs @@ -162,7 +162,7 @@ public static async Task DHBRANDS([TimerTrigger("%Schedule%")] TimerInfo myTimer { // ADD to EMPTY QUEUE var newMsgId = await AddQueueMessageAsync(log, qConnString, qName, dh.DataHolderBrandId, "NO REG (ADD to EMPTY QUEUE)"); - await UpdateDcrMessage(log, dbConnString, dh.DataHolderBrandId, dh.BrandName, dh.EndpointDetail.InfoSecBaseUri, dcrMsgId, newMsgId, "UPDATE DcrMessage table"); + await UpdateDcrMessage(log, dbConnString, dh.DataHolderBrandId, dh.BrandName, dh.EndpointDetail.InfoSecBaseUri, dcrMsgId, MessageEnum.Pending, newMsgId, "UPDATE DcrMessage table"); pendingReg++; } @@ -170,13 +170,15 @@ public static async Task DHBRANDS([TimerTrigger("%Schedule%")] TimerInfo myTimer else if (qCount < 33) { var ifExist = await IsMessageInQueue(dcrMsgId, qConnString, qName); - if (!ifExist && dcrMsgState.Equals(MessageEnum.Pending.ToString())) + if (!ifExist && (dcrMsgState.Equals(MessageEnum.Pending.ToString()) || dcrMsgState.Equals(MessageEnum.DCRFailed.ToString())) ) { + Enum.TryParse(dcrMsgState, out MessageEnum dcrMsgStatus); + // DcrMessage STATE = Pending -> ADD MESSAGE to the QUEUE var newMsgId = await AddQueueMessageAsync(log, qConnString, qName, dh.DataHolderBrandId, "NO REG (ADD to QUEUE)"); // UPDATE EXISTING DcrMessage (with ADDED Queue MessageId) - await UpdateDcrMessage(log, dbConnString, dh.DataHolderBrandId, dh.BrandName, dh.EndpointDetail.InfoSecBaseUri, dcrMsgId, newMsgId, "Update DcrMessage table"); + await UpdateDcrMessage(log, dbConnString, dh.DataHolderBrandId, dh.BrandName, dh.EndpointDetail.InfoSecBaseUri, dcrMsgId, dcrMsgStatus, newMsgId, "Update DcrMessage table"); pendingReg++; } } @@ -416,15 +418,15 @@ private static async Task AddDcrMessage(ILogger log, string dbConnString, string /// Update the DcrMessage table /// /// Message Id - private static async Task UpdateDcrMessage(ILogger log, string dbConnString, string dhBrandId, string brandName, string infosecBaseUri, string msgId, string newMsgId, string proc) + private static async Task UpdateDcrMessage(ILogger log, string dbConnString, string dhBrandId, string brandName, string infosecBaseUri, string msgId, MessageEnum messageState, string newMsgId, string proc) { DcrMessage dcrMsg = new() { DataHolderBrandId = new Guid(dhBrandId), BrandName = brandName, InfosecBaseUri = infosecBaseUri, - MessageId = msgId, - MessageState = MessageEnum.Pending.ToString() + MessageId = msgId, + MessageState = messageState.ToString() }; await new SqlDataAccess(dbConnString).UpdateDcrMsgReplaceMessageId(dcrMsg, newMsgId); @@ -462,7 +464,7 @@ private static async Task DeleteAllMessagesAsync(ILogger log, string qConnString { await qClient.DeleteAsync(); int qCount = await GetQueueCountAsync(qConnString, qName); - log.LogInformation($"{qCount} items in {qName} queue"); + log.LogInformation($"{qCount} items deleted in {qName} queue"); } } diff --git a/Source/Dockerfile.e2e-tests b/Source/Dockerfile.e2e-tests index 0707852..fd5a8eb 100644 --- a/Source/Dockerfile.e2e-tests +++ b/Source/Dockerfile.e2e-tests @@ -1,23 +1,7 @@ # Dockerfile for E2E tests -# Playwright needs Ubuntu so the dependancies install, hence we use "-focal" image (ie Ubuntu) -FROM mcr.microsoft.com/dotnet/sdk:6.0-focal AS build - -############################################################################### -# Install Playwright -############################################################################### -# Install Playwright tool -RUN dotnet tool install --global Microsoft.Playwright.CLI - -# Create sample app so that can run "playwright install" and "playwright install-deps" -RUN dotnet new console -n PlaywrightSetup -WORKDIR /PlaywrightSetup -RUN dotnet add package Microsoft.Playwright -RUN dotnet build - -# Install playwright dependancies -RUN /root/.dotnet/tools/playwright install -RUN /root/.dotnet/tools/playwright install-deps +# NB: Ensure playwright version used in CDR.DataRecipient.E2ETests and base image below are same (ie currently 1.24.1) +FROM mcr.microsoft.com/playwright/dotnet:v1.24.1-focal as build ############################################################################### # Build E2E tests @@ -33,12 +17,13 @@ COPY . ./ # Install developer certificate RUN dotnet dev-certs https -# Run tests +# Build tests WORKDIR /src/CDR.DataRecipient.E2ETests RUN dotnet build --configuration Release -# Install playwright dependancies - for some reason need to do this in addition to the first time above -RUN /root/.dotnet/tools/playwright install - +############################################################################### +# Run E2E tests +############################################################################### ENTRYPOINT ["dotnet", "test", "--configuration", "Release", "--no-build", "--logger", "trx;verbosity=detailed;LogFileName=results.trx", "-r", "/testresults"] # ENTRYPOINT ["dotnet", "test", "--configuration", "Release", "--no-build", "--filter", "AC04_DynamicClientRegistration", "--logger", "trx;verbosity=detailed;LogFileName=results.trx", "-r", "/testresults"] +# ENTRYPOINT ["tail", "-f", "/dev/null"] diff --git a/Source/docker-compose.E2ETests.yml b/Source/docker-compose.E2ETests.yml index b4250fb..3634124 100644 --- a/Source/docker-compose.E2ETests.yml +++ b/Source/docker-compose.E2ETests.yml @@ -83,9 +83,9 @@ services: - "./_temp/mock-data-recipient/tmp:/tmp" healthcheck: test: test -f /app/web/_healthcheck_ready || exit 1 - timeout: 10s - interval: 30s - retries: 20 + timeout: 5s + interval: 5s + retries: 100 depends_on: mock-data-holder-energy: condition: service_healthy @@ -116,6 +116,6 @@ services: - SA_PASSWORD=Pa{}w0rd2019 healthcheck: test: /opt/mssql-tools/bin/sqlcmd -S . -U sa -P "Pa{}w0rd2019" -Q "SELECT 1" || exit 1 - timeout: 10s - interval: 10s - retries: 30 + timeout: 5s + interval: 5s + retries: 100 diff --git a/Source/docker-compose.IntegrationTests.yml b/Source/docker-compose.IntegrationTests.yml index 858b2f7..23825b7 100644 --- a/Source/docker-compose.IntegrationTests.yml +++ b/Source/docker-compose.IntegrationTests.yml @@ -89,9 +89,9 @@ services: - "./_temp/mock-data-recipient/tmp:/tmp" healthcheck: test: test -f /app/web/_healthcheck_ready || exit 1 - timeout: 10s - interval: 10s - retries: 30 + timeout: 5s + interval: 5s + retries: 100 depends_on: mock-data-holder-energy: condition: service_healthy @@ -122,6 +122,6 @@ services: - SA_PASSWORD=Pa{}w0rd2019 healthcheck: test: /opt/mssql-tools/bin/sqlcmd -S . -U sa -P "Pa{}w0rd2019" -Q "SELECT 1" || exit 1 - timeout: 10s - interval: 10s - retries: 30 + timeout: 5s + interval: 5s + retries: 100 diff --git a/Source/tests.Development-WithDockerCompose.runsettings b/Source/tests.Development-WithDockerCompose.runsettings new file mode 100644 index 0000000..1caf4fa --- /dev/null +++ b/Source/tests.Development-WithDockerCompose.runsettings @@ -0,0 +1,14 @@ + + + + + + + Development-WithDockerCompose + + + \ No newline at end of file