diff --git a/.github/workflows/net-ci.yml b/.github/workflows/net-ci.yml index 4027702..aba2eda 100644 --- a/.github/workflows/net-ci.yml +++ b/.github/workflows/net-ci.yml @@ -12,47 +12,24 @@ jobs: # Build and test on .NET Core dotnet-core-ci: name: .NET Core - test - runs-on: windows-2022 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up .NET - uses: actions/setup-dotnet@v1.7.2 + uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ matrix.dotnet-version }} + dotnet-version: '8.0.x' - name: Install dependencies - run: nuget restore + run: dotnet restore - name: Build solution - run: dotnet build + run: dotnet build --configuration Release --no-restore - name: Run tests - run: dotnet test + run: dotnet test --no-restore --verbosity normal --logger:"trx;LogFileName=TestResults.xml" # - name: Run linter # run: dotnet format --verify-no-changes - - # Build and test on .NET Framework - dotnet-framework-ci: - name: .NET Framework - test - runs-on: windows-2022 - - steps: - - uses: actions/checkout@v2 - - - name: Set up MSBuild - uses: microsoft/setup-msbuild@v1 - - - name: Set up VSTest - uses: darenm/Setup-VSTest@v1 - - - name: Install dependencies - run: nuget restore - - - name: Build DuoApiTest solution - run: msbuild.exe duo_api_csharp.sln - - - name: Run Tests dll - run: vstest.console.exe .\test\bin\Debug\DuoApiTest.dll diff --git a/.gitignore b/.gitignore index 8dd0034..55e3150 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ packages/ *.ncrunchsolution _NCrunch_*/ +.idea +.DS_Store diff --git a/Makefile b/Makefile deleted file mode 100644 index cf05dce..0000000 --- a/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -EXES := DuoAdmin.exe - -all: $(EXES) - -%.exe: %.cs ../Duo.cs - dmcs -r:System.Web.Services -r:System.Web.Extensions -r:System.Web $< ../Duo.cs -out:$@ - -clean: - rm -f $(EXES) *~ diff --git a/build.cmd b/build.cmd deleted file mode 100644 index 478b0cd..0000000 --- a/build.cmd +++ /dev/null @@ -1,16 +0,0 @@ -:: Example script for building without the full Visual Studio - -:: Install prerequisites using chocolately package manager -choco install -y visualstudio2019buildtools --package-parameters "--add Microsoft.VisualStudio.Workload.VCTools;includeRecommended" nuget.commandline nunit-console-runner - -:: Add build tools to path -IF "'%VSINSTALLDIR%'" EQU "''" (call "C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Auxiliary\Build\vcvars64.bat") else (echo "vcvars already set") - -:: Restore nuget packages -nuget restore - -:: Build debug version of the project -msbuild duo_api_csharp.sln /p:Configuration=Debug /p:Platform="Any CPU" - -:: Run unit tests -vstest.console.exe .\test\bin\Debug\DuoApiTest.dll \ No newline at end of file diff --git a/duo_api_csharp.Examples/Program.cs b/duo_api_csharp.Examples/Program.cs new file mode 100644 index 0000000..83b1a55 --- /dev/null +++ b/duo_api_csharp.Examples/Program.cs @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using duo_api_csharp; +using duo_api_csharp.Classes; + +namespace Examples +{ + public class Program + { + private static async Task Main(string[] args) + { + // Setup the connection + if( args.Length < 3 ) + { + Console.WriteLine("Usage: "); + return 1; + } + + var ikey = args[0]; + var skey = args[1]; + var host = args[2]; + var client = new DuoAPI(ikey, skey, host); + + // Request the first 100 users + try + { + var userResult = await client.Admin_v1.Users.GetUsers(); + if( userResult.Response == null ) + { + Console.WriteLine("Unable to retrieve users from Duo: "); + Console.WriteLine($"Error: ({userResult.ErrorCode}) {userResult.ErrorMessage}"); + if( !string.IsNullOrEmpty(userResult.ErrorMessageDetail) ) Console.WriteLine($"Detailed Error: {userResult.ErrorMessageDetail}"); + return 1; + } + + // List first 100 of the available users + Console.WriteLine($"Retrieved {userResult.Response.Count()} Users"); + foreach( var user in userResult.Response ) + { + Console.WriteLine($"User retrieved: {user.Username} (ID: {user.UserID})"); + } + } + catch( DuoException Ex ) + { + Console.WriteLine("Unable to retrieve users from Duo: "); + Console.WriteLine($"Exception: {Ex.Message}, Status Code: {Ex.StatusCode}, Request Success: {Ex.RequestSuccess}"); + if( Ex.InnerException != null ) Console.WriteLine($"Inner Exception: {Ex.InnerException.Message}"); + } + + return 0; + } + } +} \ No newline at end of file diff --git a/duo_api_csharp.Examples/duo_api_csharp.Examples.csproj b/duo_api_csharp.Examples/duo_api_csharp.Examples.csproj new file mode 100644 index 0000000..1675dab --- /dev/null +++ b/duo_api_csharp.Examples/duo_api_csharp.Examples.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + latestmajor + Exe + + + + Examples + + + + false + None + false + + + + + + + + + + + \ No newline at end of file diff --git a/duo_api_csharp.Tests/Classes/Tests_CertificatePinning.cs b/duo_api_csharp.Tests/Classes/Tests_CertificatePinning.cs new file mode 100644 index 0000000..600d0c5 --- /dev/null +++ b/duo_api_csharp.Tests/Classes/Tests_CertificatePinning.cs @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using System.Net.Security; +using duo_api_csharp.Classes; +using System.Security.Cryptography.X509Certificates; + +namespace duo_api_csharp.Tests.Classes +{ + public class Tests_CertificatePinning + { + #region Static Methods + // Helper methods and some hard-coded certificates + protected static X509Certificate2 DuoApiServerCert() + { + // The leaf certificate for api-*.duosecurity.com + return CertFromString(DUO_API_CERT_SERVER); + } + + protected static X509Chain DuoApiChain() + { + // The certificate chain for api-*.duosecurity.com + var chain = new X509Chain(); + + // Verify as of a date that the certs are valid for + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.VerificationTime = new DateTime(2023, 01, 01); + chain.ChainPolicy.CustomTrustStore.Add(CertFromString(DUO_API_CERT_ROOT)); + chain.ChainPolicy.ExtraStore.Add(CertFromString(DUO_API_CERT_INTER)); + Assert.True(chain.Build(DuoApiServerCert())); + return chain; + } + + protected static X509Chain MicrosoftComChain() + { + // A valid chain, but for www.microsoft.com, not Duo + var chain = new X509Chain(); + + // Verify as of a date that the certs are valid for + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.VerificationTime = new DateTime(2023, 01, 01); + chain.ChainPolicy.CustomTrustStore.Add(CertFromString(MICROSOFT_COM_CERT_ROOT)); + chain.ChainPolicy.ExtraStore.Add(CertFromString(MICROSOFT_COM_CERT_INTER)); + Assert.True(chain.Build(CertFromString(MICROSOFT_COM_CERT_SERVER))); + return chain; + } + + protected static X509Chain InvalidChain() + { + // Nonsense certificate chain + var chain = new X509Chain(); + + // Verify as of a date that the certs are valid for + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.VerificationTime = new DateTime(2023, 01, 01); + chain.ChainPolicy.ExtraStore.Add(CertFromString(MICROSOFT_COM_CERT_INTER)); + Assert.False(chain.Build(DuoApiServerCert())); + return chain; + } + + protected static X509Certificate2 CertFromString(string certString) + { + return new X509Certificate2(Convert.FromBase64String(certString)); + } + + // Certificates exported from the web site 2022-03-09 + protected const string DUO_API_CERT_SERVER = "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"; + + // Certificates exported from the web sites 2021-09-22 + protected const string DUO_API_CERT_INTER = "MIIEsTCCA5mgAwIBAgIQBOHnpNxc8vNtwCtCuF0VnzANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTEzMTAyMjEyMDAwMFoXDTI4MTAyMjEyMDAwMFowcDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEvMC0GA1UEAxMmRGlnaUNlcnQgU0hBMiBIaWdoIEFzc3VyYW5jZSBTZXJ2ZXIgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC24C/CJAbIbQRf1+8KZAayfSImZRauQkCbztyfn3YHPsMwVYcZuU+UDlqUH1VWtMICKq/QmO4LQNfE0DtyyBSe75CxEamu0si4QzrZCwvV1ZX1QK/IHe1NnF9Xt4ZQaJn1itrSxwUfqJfJ3KSxgoQtxq2lnMcZgqaFD15EWCo3j/018QsIJzJa9buLnqS9UdAn4t07QjOjBSjEuyjMmqwrIw14xnvmXnG3Sj4I+4G3FhahnSMSTeXXkgisdaScus0Xsh5ENWV/UyU50RwKmmMbGZJ0aAo3wsJSSMs5WqK24V3B3aAguCGikyZvFEohQcftbZvySC/zA/WiaJJTL17jAgMBAAGjggFJMIIBRTASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wSwYDVR0fBEQwQjBAoD6gPIY6aHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0SGlnaEFzc3VyYW5jZUVWUm9vdENBLmNybDA9BgNVHSAENjA0MDIGBFUdIAAwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAdBgNVHQ4EFgQUUWj/kK8CB3U8zNllZGKiErhZcjswHwYDVR0jBBgwFoAUsT7DaQP4v0cB1JgmGggC72NkK8MwDQYJKoZIhvcNAQELBQADggEBABiKlYkD5m3fXPwdaOpKj4PWUS+Na0QWnqxj9dJubISZi6qBcYRb7TROsLd5kinMLYBq8I4g4Xmk/gNHE+r1hspZcX30BJZr01lYPf7TMSVcGDiEo+afgv2MW5gxTs14nhr9hctJqvIni5ly/D6q1UEL2tU2ob8cbkdJf17ZSHwD2f2LSaCYJkJA69aSEaRkCldUxPUd1gJea6zuxICaEnL6VpPX/78whQYwvwt/Tv9XBZ0k7YXDK/umdaisLRbvfXknsuvCnQsH6qqF0wGjIChBWUMo0oHjqvbsezt3tkBigAVBRQHvFwY+3sAzm2fTYS5yh+Rp/BIAV0AecPUeybQ="; + protected const string DUO_API_CERT_ROOT = "MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCevEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K"; + + // Certificates exported from the web sites 2022-08-19 + protected const string MICROSOFT_COM_CERT_SERVER = "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"; + protected const string MICROSOFT_COM_CERT_INTER = "MIIFWjCCBEKgAwIBAgIQDxSWXyAgaZlP1ceseIlB4jANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTIwMDcyMTIzMDAwMFoXDTI0MTAwODA3MDAwMFowTzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEgMB4GA1UEAxMXTWljcm9zb2Z0IFJTQSBUTFMgQ0EgMDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCqYnfPmmOyBoTzkDb0mfMUUavqlQo7Rgb9EUEf/lsGWMk4bgj8T0RIzTqk970eouKVuL5RIMW/snBjXXgMQ8ApzWRJCZbar879BV8rKpHoAW4uGJssnNABf2n17j9TiFy6BWy+IhVnFILyLNK+W2M3zK9gheiWa2uACKhuvgCca5Vw/OQYErEdG7LBEzFnMzTmJcliW1iCdXby/vI/OxbfqkKD4zJtm45DJvC9Dh+hpzqvLMiK5uo/+aXSJY+SqhoIEpz+rErHw+uAlKuHFtEjSeeku8eR3+Z5ND9BSqc6JtLqb0bjOHPm5dSRrgt4nnil75bjc9j3lWXpBb9PXP9Sp/nPCK+nTQmZwHGjUnqlO9ebAVQD47ZisFonnDAmjrZNVqEXF3p7laEHrFMxttYuD81BdOzxAbL9Rb/8MeFGQjE2Qx65qgVfhH+RsYuuD9dUw/3wZAhq05yO6nk07AM9c+AbNtRoEcdZcLCHfMDcbkXKNs5DJncCqXAN6LhXVERCw/usG2MmCMLSIx9/kwt8bwhUmitOXc6fpT7SmFvRAtvxg84wUkg4Y/Gx++0j0z6StSeN0EJz150jaHG6WV4HUqaWTb98Tm90IgXAU4AW2GBOlzFPiU5IY9jt+eXC2Q6yC/ZpTL1LAcnL3Qa/OgLrHN0wiw1KFGD51WRPQ0Sh7QIDAQABo4IBJTCCASEwHQYDVR0OBBYEFLV2DDARzseSQk1Mx1wsyKkM6AtkMB8GA1UdIwQYMBaAFOWdWTCCR1jMrPoIVDaGezq1BE3wMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vT21uaXJvb3QyMDI1LmNybDAqBgNVHSAEIzAhMAgGBmeBDAECATAIBgZngQwBAgIwCwYJKwYBBAGCNyoBMA0GCSqGSIb3DQEBCwUAA4IBAQCfK76SZ1vae4qt6P+dTQUO7bYNFUHR5hXcA2D59CJWnEj5na7aKzyowKvQupW4yMH9fGNxtsh6iJswRqOOfZYC4/giBO/gNsBvwr8uDW7t1nYoDYGHPpvnpxCM2mYfQFHq576/TmeYu1RZY29C4w8xYBlkAA8mDJfRhMCmehk7cN5FJtyWRj2cZj/hOoI45TYDBChXpOlLZKIYiG1giY16vhCRi6zmPzEwv+tk156N6cGSVm44jTQ/rs1sa0JSYjzUaYngoFdZC4OfxnIkQvUIA4TOFmPzNPEFdjcZsgbeEz4TcGHTBPK4R28F44qIMCtHRV55VMX53ev6P3hRddJb"; + protected const string MICROSOFT_COM_CERT_ROOT = "MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoXDTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9yZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVyVHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKrmD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjrIZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeKmpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSuXmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZydc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/yejl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT929hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3WgxjkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhzksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLSR9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp"; + #endregion Static Methods + + [Fact] + public void Test_ReadingEmbeddedCerts_Returns10Certificates() + { + Assert.Equal(10, CertificatePinnerFactory._ReadCertsFromFile().Length); + } + + [Fact] + public void Test_ReadingEmbeddedCerts_WithInvalidResourceName_ThrowsApplicationException() + { + Assert.Throws(() => CertificatePinnerFactory._ReadCertsFromFile("fake_resource")); + } + + [Fact] + public void Test_UsingDuoPinner_AndDuoAPICert_TLSValid() + { + var pinner = CertificatePinnerFactory.GetDuoCertificatePinner(); + Assert.True(pinner(null!, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDuoPinner_AndNullCert_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetDuoCertificatePinner(); + Assert.False(pinner(null!, null, DuoApiChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDuoPinner_AndAPICertWithoutChain_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetDuoCertificatePinner(); + Assert.False(pinner(null!, DuoApiServerCert(), null, SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDuoPinner_MismatchedCertName_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetDuoCertificatePinner(); + Assert.False(pinner(null!, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.RemoteCertificateNameMismatch)); + } + + [Fact] + public void Test_UsingDuoPinner_MismatchedChain_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetDuoCertificatePinner(); + Assert.False(pinner(null!, DuoApiServerCert(), MicrosoftComChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDuoPinner_InvalidChain_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetDuoCertificatePinner(); + Assert.False(pinner(null!, DuoApiServerCert(), InvalidChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingCustomPinner_ValidChain_TLSValid() + { + var certCollection = new X509Certificate2Collection + { + CertFromString(MICROSOFT_COM_CERT_ROOT) + }; + + var pinner = CertificatePinnerFactory.GetCustomRootCertificatesPinner(certCollection); + Assert.True(pinner(null!, CertFromString(MICROSOFT_COM_CERT_SERVER), MicrosoftComChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDisabledPinner_TLSValid() + { + var pinner = CertificatePinnerFactory.GetCertificateDisabler(); + Assert.True(pinner(null!, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDisabledPinner_AndNullCert_TLSValid() + { + var pinner = CertificatePinnerFactory.GetCertificateDisabler(); + Assert.True(pinner(null!, null, DuoApiChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDisabledPinner_AndAPICertWithoutChain_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetCertificateDisabler(); + Assert.True(pinner(null!, DuoApiServerCert(), null, SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDisabledPinner_MismatchedCertName_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetCertificateDisabler(); + Assert.True(pinner(null!, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.RemoteCertificateNameMismatch)); + } + + [Fact] + public void Test_UsingDisabledPinner_MismatchedChain_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetCertificateDisabler(); + Assert.True(pinner(null!, DuoApiServerCert(), MicrosoftComChain(), SslPolicyErrors.None)); + } + + [Fact] + public void Test_UsingDisabledPinner_InvalidChain_TLSNotValid() + { + var pinner = CertificatePinnerFactory.GetDuoCertificatePinner(); + Assert.False(pinner(null!, DuoApiServerCert(), InvalidChain(), SslPolicyErrors.None)); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp.Tests/Classes/Tests_DuoBoolConverter.cs b/duo_api_csharp.Tests/Classes/Tests_DuoBoolConverter.cs new file mode 100644 index 0000000..1252f5e --- /dev/null +++ b/duo_api_csharp.Tests/Classes/Tests_DuoBoolConverter.cs @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using System.Globalization; +using duo_api_csharp.Classes; + +namespace duo_api_csharp.Tests.Classes +{ + public class Tests_DuoBoolConverter + { + [Fact] + public void Test_WriteJson_ReturnsStringTrue_WithBooleanTrue() + { + // Create JSON Writer + var sw = new StringWriter(CultureInfo.InvariantCulture); + using var writer = new JsonTextWriter(sw); + + // Write true to it + new DuoBoolConverter().WriteJson(writer, true, null!); + + // Inspect result + Assert.Equal("\"true\"", sw.ToString()); + } + + [Fact] + public void Test_WriteJson_ReturnsStringFalse_WithBooleanFalse() + { + // Create JSON Writer + var sw = new StringWriter(CultureInfo.InvariantCulture); + using var writer = new JsonTextWriter(sw); + + // Write true to it + new DuoBoolConverter().WriteJson(writer, false, null!); + + // Inspect result + Assert.Equal("\"false\"", sw.ToString()); + } + + [Fact] + public void Test_ReadJson_ReturnsNull() + { + Assert.Null(new DuoBoolConverter().ReadJson(null!, null!, null!, null!)); + } + + [Fact] + public void Test_CanRead_ReturnsFalse() + { + Assert.False(new DuoBoolConverter().CanRead); + } + + [Fact] + public void Test_CanConvert_ReturnsTrue_WithBoolean() + { + Assert.True(new DuoBoolConverter().CanConvert(typeof(bool))); + } + + [Fact] + public void Test_CanConvert_ReturnsFalse_WithString() + { + Assert.False(new DuoBoolConverter().CanConvert(typeof(string))); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp.Tests/Classes/Tests_DuoModelBase.cs b/duo_api_csharp.Tests/Classes/Tests_DuoModelBase.cs new file mode 100644 index 0000000..1bec8e6 --- /dev/null +++ b/duo_api_csharp.Tests/Classes/Tests_DuoModelBase.cs @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using duo_api_csharp.Models.v1; + +namespace duo_api_csharp.Tests.Classes +{ + public class Tests_DuoModelBase + { + [Fact] + public void Test_InheritedClass_ConvertsCorrectly() + { + var ChildClass = new DuoUserResponseModel + { + CreatedOn = DateTime.Now, + Username = "Bob" + }; + + Assert.NotNull(ChildClass.CreatedOn); + var ParentClass = Assert.IsType(ChildClass.GetBaseClass(typeof(DuoUserRequestModel))); + Assert.IsNotType(ParentClass); + Assert.Equal("Bob", ParentClass.Username); + } + + [Fact] + public void Test_MismatchedClass_ThrowsException() + { + var ChildClass = new DuoUserResponseModel + { + CreatedOn = DateTime.Now, + Username = "Bob" + }; + + Assert.Throws(() => ChildClass.GetBaseClass(typeof(DuoGroupRequestModel))); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp.Tests/Classes/Tests_Epoch.cs b/duo_api_csharp.Tests/Classes/Tests_Epoch.cs new file mode 100644 index 0000000..17f84f6 --- /dev/null +++ b/duo_api_csharp.Tests/Classes/Tests_Epoch.cs @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using duo_api_csharp.Classes; + +namespace duo_api_csharp.Tests.Classes +{ + public class Tests_DuoEpoch + { + [Fact] + public void Test_FromUnix_WithNull_ReturnsNull() + { + Assert.Null(Epoch.FromUnix(null)); + } + + [Fact] + public void Test_FromUnix_ReturnsValidStamp() + { + var RefDate = new DateTime(2024, 8, 31, 22, 0, 0, DateTimeKind.Utc); + var Converted = Epoch.FromUnix(1725141600); + Assert.Equal(DateTimeKind.Utc, Converted!.Value.Kind); + Assert.Equal(RefDate, Converted); + } + + [Fact] + public void Test_ToUnix_WithNull_ReturnsNull() + { + Assert.Null(Epoch.ToUnix(null)); + } + + [Fact] + public void Test_ToUnix_ReturnsValidStamp() + { + var RefDate = new DateTime(2024, 8, 31, 22, 0, 0, DateTimeKind.Utc); + var Converted = Epoch.ToUnix(RefDate); //1725141600 + Assert.Equal(1725141600, Converted); + } + + [Fact] + public void Test_Now_ReturnsValidStamp() + { + Assert.Equal(Epoch.ToUnix(DateTime.UtcNow), Epoch.Now); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV2.cs b/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV2.cs new file mode 100644 index 0000000..a448e11 --- /dev/null +++ b/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV2.cs @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Xunit.Abstractions; +using duo_api_csharp.Models; +using duo_api_csharp.Extensions; +using duo_api_csharp.SignatureTypes; + +namespace duo_api_csharp.Tests.SignatureTypes +{ + public class Tests_DuoSignatureV2(ITestOutputHelper output) + { + private const string TEST_IKEY = "test_ikey"; + private const string TEST_SKEY = "gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT"; + private const string TEST_HOST = "foo.bar52.com"; + private static readonly DateTime TEST_TIME = new(2012, 12, 7, 17, 18, 0, DateTimeKind.Utc); + private static readonly DuoSignatureV2 INSTANCE = new(TEST_IKEY, TEST_SKEY, TEST_HOST, TEST_TIME); + + #region Generic Tests + [Fact] + public void Test_SignatureType_MatchesV2() + { + Assert.Equal(DuoSignatureTypes.Duo_SignatureTypeV2, INSTANCE.SignatureType); + } + + [Fact] + public void Test_RequestHeaders_ContainsOnlyDate() + { + var expected = new Dictionary + { + { "X-Duo-Date", TEST_TIME.DateToRFC822() } + }; + + Assert.Equal(expected, INSTANCE.DefaultRequestHeaders); + } + #endregion Generic Tests + + #region CanonParams Tests + private void AssertCanonParams(Dictionary param, string expected) + { + var canonData = INSTANCE._CanonParams(new DuoParamRequestData + { + RequestData = param + }); + + output.WriteLine($"Expected: {expected}"); + output.WriteLine($"Actual: {canonData}"); + Assert.Equal(expected, canonData); + } + + [Fact] + public void Test_CanonParams_ZeroParams() + { + AssertCanonParams(new Dictionary(), ""); + } + + [Fact] + public void Test_CanonParams_OneParam() + { + AssertCanonParams(new Dictionary { {"realname", "First Last"} }, + "realname=First%20Last"); + } + + [Fact] + public void Test_CanonParams_TwoParams() + { + AssertCanonParams(new Dictionary { {"realname", "First Last"}, {"username", "root"} }, + "realname=First%20Last&username=root"); + } + + [Fact] + public void Test_CanonParams_boolean_true_int_and_string() + { + AssertCanonParams(new Dictionary { {"words", "First Last"}, {"success", "true"}, {"digit", "5"} }, + "digit=5&success=true&words=First%20Last"); + } + + [Fact] + public void Test_CanonParams_boolean_false_int_and_string() + { + AssertCanonParams(new Dictionary { { "words", "First Last" }, { "success", "false" }, { "digit", "5" } }, + "digit=5&success=false&words=First%20Last"); + } + + [Fact] + public void Test_CanonParams_list_string() + { + AssertCanonParams(new Dictionary { { "realname", "First Last" }, { "username", "root" } }, + "realname=First%20Last&username=root"); + } + + [Fact] + public void Test_CanonParams_printable_ascii_characters() + { + AssertCanonParams(new Dictionary { + { "digits", "0123456789" }, + { "letters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" }, + { "punctuation", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" }, + { "whitespace", "\t\n\x0b\x0c\r " } + }, + "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20" + ); + } + + [Fact] + public void Test_CanonParams_unicode_fuzz_values() + { + AssertCanonParams(new Dictionary + { + { "bar", "\u2815\uaaa3\u37cf\u4bb7\u36e9\ucc05\u668e\u8162\uc2bd\ua1f1" }, + { "baz", "\u0df3\u84bd\u5669\u9985\ub8a4\uac3a\u7be7\u6f69\u934a\ub91c" }, + { "foo", "\ud4ce\ud6d6\u7938\u50c0\u8a20\u8f15\ufd0b\u8024\u5cb3\uc655" }, + { "qux", "\u8b97\uc846-\u828e\u831a\uccca\ua2d4\u8c3e\ub8b2\u99be" } + }, + "bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE" + ); + } + + [Fact] + public void Test_CanonParams_unicode_fuzz_keys_and_values() + { + AssertCanonParams(new Dictionary + { + { "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0" }, + { "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30" }, + { "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3" }, + { "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + }, + "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU" + ); + } + + [Fact] + public void Test_CanonParams_sort_order_with_common_prefix() + { + AssertCanonParams(new Dictionary + { + { "foo_bar", "2" }, + { "foo", "1" } + }, + "foo=1&foo_bar=2" + ); + } + #endregion CanonParams Tests + + #region Signature Tests + [Fact] + public void Test_Signature() + { + const string expectedResult = "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU"; + var requestdata = new DuoParamRequestData + { + RequestData = new Dictionary { + { "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0" }, + { "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30" }, + { "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3" }, + { "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + } + }; + + var testSign = INSTANCE._GenerateSignature(HttpMethod.Post, "/Foo/BaR2/qux", TEST_TIME, requestdata); + output.WriteLine($"Expected: {expectedResult}"); + output.WriteLine($"Actual: {testSign}"); + Assert.Equal(expectedResult, testSign); + } + + [Fact] + public void Test_Signature_HMAC512() + { + const string expectedResult = "dGVzdF9pa2V5OjA1MDgwNjUwMzVhMDNiMmExZGUyZjQ1M2U2MjllNzkxZDE4MDMyOWUxNTdmNjVkZjZiM2UwZjA4Mjk5ZDQzMjFlMWM1YzdhN2M3ZWU2YjllNWZjODBkMWZiNmZiZjNhZDVlYjdjNDRkZDNiMzk4NWEwMmMzN2FjYTUzZWMzNjk4"; + var requestdata = new DuoParamRequestData + { + RequestData = new Dictionary { + { "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0" }, + { "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30" }, + { "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3" }, + { "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + } + }; + + var testSign = INSTANCE.SignRequest(HttpMethod.Post, "/Foo/BaR2/qux", TEST_TIME, requestdata, null); + output.WriteLine($"Expected: {expectedResult}"); + output.WriteLine($"Actual: {testSign}"); + Assert.Equal(expectedResult, testSign); + } + #endregion Signature Tests + } +} \ No newline at end of file diff --git a/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV4.cs b/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV4.cs new file mode 100644 index 0000000..724e89d --- /dev/null +++ b/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV4.cs @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Xunit.Abstractions; +using duo_api_csharp.Models; +using duo_api_csharp.Extensions; +using duo_api_csharp.SignatureTypes; + +namespace duo_api_csharp.Tests.SignatureTypes +{ + public class Tests_DuoSignatureV4(ITestOutputHelper output) + { + private const string TEST_IKEY = "test_ikey"; + private const string TEST_SKEY = "gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT"; + private const string TEST_HOST = "foo.bar52.com"; + private const string TEST_JSONSTRING = "{\"alpha\":[\"a\",\"b\",\"c\",\"d\"],\"data\":\"abc123\",\"info\":{\"another\":2,\"test\":1}}"; + private static readonly DateTime TEST_TIME = new(2012, 12, 7, 17, 18, 0, DateTimeKind.Utc); + private static readonly DuoSignatureV4 INSTANCE = new(TEST_IKEY, TEST_SKEY, TEST_HOST, TEST_TIME); + private static readonly object TEST_JSONOBJECT = new { + alpha = new []{"a", "b", "c", "d"}, + data = "abc123", + info = new + { + another = 2, + test = 1 + } + }; + + #region Generic Tests + [Fact] + public void Test_SignatureType_MatchesV2() + { + Assert.Equal(DuoSignatureTypes.Duo_SignatureTypeV4, INSTANCE.SignatureType); + } + + [Fact] + public void Test_RequestHeaders_ContainsOnlyDate() + { + var expected = new Dictionary + { + { "X-Duo-Date", TEST_TIME.DateToRFC822() } + }; + + Assert.Equal(expected, INSTANCE.DefaultRequestHeaders); + } + #endregion Generic Tests + + #region CanonParams Tests + private void AssertCanonParams(Dictionary param, string expected) + { + var canonData = INSTANCE._CanonParams(new DuoParamRequestData + { + RequestData = param + }); + + output.WriteLine($"Expected: {expected}"); + output.WriteLine($"Actual: {canonData}"); + Assert.Equal(expected, canonData); + } + + [Fact] + public void Test_CanonParams_ZeroParams() + { + AssertCanonParams(new Dictionary(), ""); + } + + [Fact] + public void Test_CanonParams_OneParam() + { + AssertCanonParams(new Dictionary { {"realname", "First Last"} }, + "realname=First%20Last"); + } + + [Fact] + public void Test_CanonParams_TwoParams() + { + AssertCanonParams(new Dictionary { {"realname", "First Last"}, {"username", "root"} }, + "realname=First%20Last&username=root"); + } + + [Fact] + public void Test_CanonParams_boolean_true_int_and_string() + { + AssertCanonParams(new Dictionary { {"words", "First Last"}, {"success", "true"}, {"digit", "5"} }, + "digit=5&success=true&words=First%20Last"); + } + + [Fact] + public void Test_CanonParams_boolean_false_int_and_string() + { + AssertCanonParams(new Dictionary { { "words", "First Last" }, { "success", "false" }, { "digit", "5" } }, + "digit=5&success=false&words=First%20Last"); + } + + [Fact] + public void Test_CanonParams_list_string() + { + AssertCanonParams(new Dictionary { { "realname", "First Last" }, { "username", "root" } }, + "realname=First%20Last&username=root"); + } + + [Fact] + public void Test_CanonParams_printable_ascii_characters() + { + AssertCanonParams(new Dictionary { + { "digits", "0123456789" }, + { "letters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" }, + { "punctuation", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" }, + { "whitespace", "\t\n\x0b\x0c\r " } + }, + "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20" + ); + } + + [Fact] + public void Test_CanonParams_unicode_fuzz_values() + { + AssertCanonParams(new Dictionary + { + { "bar", "\u2815\uaaa3\u37cf\u4bb7\u36e9\ucc05\u668e\u8162\uc2bd\ua1f1" }, + { "baz", "\u0df3\u84bd\u5669\u9985\ub8a4\uac3a\u7be7\u6f69\u934a\ub91c" }, + { "foo", "\ud4ce\ud6d6\u7938\u50c0\u8a20\u8f15\ufd0b\u8024\u5cb3\uc655" }, + { "qux", "\u8b97\uc846-\u828e\u831a\uccca\ua2d4\u8c3e\ub8b2\u99be" } + }, + "bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE" + ); + } + + [Fact] + public void Test_CanonParams_unicode_fuzz_keys_and_values() + { + AssertCanonParams(new Dictionary + { + { "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0" }, + { "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30" }, + { "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3" }, + { "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + }, + "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU" + ); + } + + [Fact] + public void Test_CanonParams_sort_order_with_common_prefix() + { + AssertCanonParams(new Dictionary + { + { "foo_bar", "2" }, + { "foo", "1" } + }, + "foo=1&foo_bar=2" + ); + } + #endregion CanonParams Tests + + #region Signature Tests + [Fact] + public void Test_Signature() + { + const string expectedResult = "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n\nc30ca4ffc7fe4272aa6ae7a3c94cf71c11ed8ae7aaa32e81a401a59f1cef0866ccb02304380cdc48a813b1566c457653fa62736022f0cfeadcec8cd7c6233480"; + + var testSign = INSTANCE._GenerateSignature(HttpMethod.Post, "/Foo/BaR2/qux", TEST_TIME, new DuoJsonRequestDataObject(TEST_JSONOBJECT)); + output.WriteLine($"Expected: {expectedResult}"); + output.WriteLine($"Actual: {testSign}"); + Assert.Equal(expectedResult, testSign); + } + + [Fact] + public void Test_Signature_HMAC512() + { + const string expectedResult = "dGVzdF9pa2V5OjIxNmVmMjE5OTY5MzY5MzZkMGZiYzQ4NDc3N2Q0ZjRmMWIzYTE4YjUyZjY1ZDk5MmIwMmRkZmJhYWFlZTRiNWZkMTA0NGMzNDk3M2U1MTUwMTc0NTI4ZjU2ZTZiMGVhY2ViN2RhNGYxNjUxMmU0YzkzODVhZGE2ZmNhYTNjM2U4"; + var jsonData = new DuoJsonRequestData + { + RequestData = TEST_JSONSTRING + }; + + var testSign = INSTANCE.SignRequest(HttpMethod.Post, "/Foo/BaR2/qux", TEST_TIME, jsonData, null); + output.WriteLine($"Expected: {expectedResult}"); + output.WriteLine($"Actual: {testSign}"); + Assert.Equal(expectedResult, testSign); + } + #endregion Signature Tests + } +} \ No newline at end of file diff --git a/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV5.cs b/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV5.cs new file mode 100644 index 0000000..c668e55 --- /dev/null +++ b/duo_api_csharp.Tests/SignatureTypes/Tests_DuoSignatureV5.cs @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Xunit.Abstractions; +using duo_api_csharp.Models; +using duo_api_csharp.Extensions; +using duo_api_csharp.SignatureTypes; + +namespace duo_api_csharp.Tests.SignatureTypes +{ + public class Tests_DuoSignatureV5(ITestOutputHelper output) + { + private const string TEST_IKEY = "test_ikey"; + private const string TEST_SKEY = "gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT"; + private const string TEST_HOST = "foo.bar52.com"; + private const string TEST_JSONSTRING = "{\"alpha\":[\"a\",\"b\",\"c\",\"d\"],\"data\":\"abc123\",\"info\":{\"another\":2,\"test\":1}}"; + private static readonly DateTime TEST_TIME = new(2012, 12, 7, 17, 18, 0, DateTimeKind.Utc); + private static readonly DuoSignatureV5 INSTANCE = new(TEST_IKEY, TEST_SKEY, TEST_HOST, TEST_TIME); + private static readonly object TEST_JSONOBJECT = new { + alpha = new []{"a", "b", "c", "d"}, + data = "abc123", + info = new + { + another = 2, + test = 1 + } + }; + + #region Generic Tests + [Fact] + public void Test_SignatureType_MatchesV2() + { + Assert.Equal(DuoSignatureTypes.Duo_SignatureTypeV5, INSTANCE.SignatureType); + } + + [Fact] + public void Test_RequestHeaders_ContainsOnlyDate() + { + var expected = new Dictionary + { + { "X-Duo-Date", TEST_TIME.DateToRFC822() } + }; + + Assert.Equal(expected, INSTANCE.DefaultRequestHeaders); + } + #endregion Generic Tests + + #region CanonParams Tests + private void AssertCanonParams(Dictionary param, string expected) + { + var canonData = INSTANCE._CanonParams(new DuoParamRequestData + { + RequestData = param + }); + + output.WriteLine($"Expected: {expected}"); + output.WriteLine($"Actual: {canonData}"); + Assert.Equal(expected, canonData); + } + + [Fact] + public void Test_CanonParams_ZeroParams() + { + AssertCanonParams(new Dictionary(), ""); + } + + [Fact] + public void Test_CanonParams_OneParam() + { + AssertCanonParams(new Dictionary { {"realname", "First Last"} }, + "realname=First%20Last"); + } + + [Fact] + public void Test_CanonParams_TwoParams() + { + AssertCanonParams(new Dictionary { {"realname", "First Last"}, {"username", "root"} }, + "realname=First%20Last&username=root"); + } + + [Fact] + public void Test_CanonParams_boolean_true_int_and_string() + { + AssertCanonParams(new Dictionary { {"words", "First Last"}, {"success", "true"}, {"digit", "5"} }, + "digit=5&success=true&words=First%20Last"); + } + + [Fact] + public void Test_CanonParams_boolean_false_int_and_string() + { + AssertCanonParams(new Dictionary { { "words", "First Last" }, { "success", "false" }, { "digit", "5" } }, + "digit=5&success=false&words=First%20Last"); + } + + [Fact] + public void Test_CanonParams_list_string() + { + AssertCanonParams(new Dictionary { { "realname", "First Last" }, { "username", "root" } }, + "realname=First%20Last&username=root"); + } + + [Fact] + public void Test_CanonParams_printable_ascii_characters() + { + AssertCanonParams(new Dictionary { + { "digits", "0123456789" }, + { "letters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" }, + { "punctuation", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" }, + { "whitespace", "\t\n\x0b\x0c\r " } + }, + "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20" + ); + } + + [Fact] + public void Test_CanonParams_unicode_fuzz_values() + { + AssertCanonParams(new Dictionary + { + { "bar", "\u2815\uaaa3\u37cf\u4bb7\u36e9\ucc05\u668e\u8162\uc2bd\ua1f1" }, + { "baz", "\u0df3\u84bd\u5669\u9985\ub8a4\uac3a\u7be7\u6f69\u934a\ub91c" }, + { "foo", "\ud4ce\ud6d6\u7938\u50c0\u8a20\u8f15\ufd0b\u8024\u5cb3\uc655" }, + { "qux", "\u8b97\uc846-\u828e\u831a\uccca\ua2d4\u8c3e\ub8b2\u99be" } + }, + "bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE" + ); + } + + [Fact] + public void Test_CanonParams_unicode_fuzz_keys_and_values() + { + AssertCanonParams(new Dictionary + { + { "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0" }, + { "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30" }, + { "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3" }, + { "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + }, + "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU" + ); + } + + [Fact] + public void Test_CanonParams_sort_order_with_common_prefix() + { + AssertCanonParams(new Dictionary + { + { "foo_bar", "2" }, + { "foo", "1" } + }, + "foo=1&foo_bar=2" + ); + } + #endregion CanonParams Tests + + #region Signature Tests + [Fact] + public void Test_Signature() + { + const string expectedResult = "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n\nc30ca4ffc7fe4272aa6ae7a3c94cf71c11ed8ae7aaa32e81a401a59f1cef0866ccb02304380cdc48a813b1566c457653fa62736022f0cfeadcec8cd7c6233480\n630b4bfe7e9abd03da2eee8f0a5d4e60a254ec880a839bcc2223bb5b9443e8ef24d58f0254f1f5934bf8c017ebd0fd5b1acf86766bdbe74185e712a4092df3ed"; + var testSign = INSTANCE._GenerateSignature(HttpMethod.Post, "/Foo/BaR2/qux", TEST_TIME, new DuoJsonRequestDataObject(TEST_JSONOBJECT), new Dictionary + { + { "X-Duo-Header-1", "header_value_1" } + }); + + output.WriteLine($"Expected: {expectedResult}"); + output.WriteLine($"Actual: {testSign}"); + Assert.Equal(expectedResult, testSign); + } + + [Fact] + public void Test_Signature_WithParams() + { + const string expectedResult = "Fri, 07 Dec 2012 17:18:00 -0000\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e\n630b4bfe7e9abd03da2eee8f0a5d4e60a254ec880a839bcc2223bb5b9443e8ef24d58f0254f1f5934bf8c017ebd0fd5b1acf86766bdbe74185e712a4092df3ed"; + var requestdata = new DuoParamRequestData + { + RequestData = new Dictionary { + { "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0" }, + { "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30" }, + { "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3" }, + { "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" } + } + }; + + var testSign = INSTANCE._GenerateSignature(HttpMethod.Post, "/Foo/BaR2/qux", TEST_TIME, requestdata, new Dictionary + { + { "X-Duo-Header-1", "header_value_1" } + }); + + output.WriteLine($"Expected: {expectedResult}"); + output.WriteLine($"Actual: {testSign}"); + Assert.Equal(expectedResult, testSign); + } + + [Fact] + public void Test_Signature_HMAC512() + { + const string expectedResult = "dGVzdF9pa2V5OjY2MDc2NjEwOTcwYzIzMDU2YzhjZTBjNjZkZGQyZGIyZDBmMTA4NzZhODI1ODE0ZDkyZTllZTNkZDA0MTg5NzUyYzg4YTViZTc5ZDIwZjZkNTZjYWNjN2E5ZjE2YTZiOGU2OTVhMDAyOGE3ZjYwZWQyMTk0OTZhYzUzZGRmYWM3"; + var requestdata = new DuoJsonRequestData{ RequestData = TEST_JSONSTRING }; + var testSign = INSTANCE.SignRequest(HttpMethod.Post, "/Foo/BaR2/qux", TEST_TIME, requestdata, new Dictionary + { + { "X-Duo-Header-1", "header_value_1" } + }); + + output.WriteLine($"Expected: {expectedResult}"); + output.WriteLine($"Actual: {testSign}"); + Assert.Equal(expectedResult, testSign); + } + #endregion Signature Tests + } +} \ No newline at end of file diff --git a/duo_api_csharp.Tests/duo_api_csharp.Tests.csproj b/duo_api_csharp.Tests/duo_api_csharp.Tests.csproj new file mode 100644 index 0000000..064a29b --- /dev/null +++ b/duo_api_csharp.Tests/duo_api_csharp.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/duo_api_csharp.sln b/duo_api_csharp.sln index 743ca5f..ed41acf 100644 --- a/duo_api_csharp.sln +++ b/duo_api_csharp.sln @@ -4,15 +4,15 @@ VisualStudioVersion = 17.0.32126.317 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "duo_api_csharp", "duo_api_csharp\duo_api_csharp.csproj", "{6E96C9D9-0825-4D26-83C7-8A62180F8FB9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DuoApiTest", "test\DuoApiTest.csproj", "{6B97B9FB-E553-494C-BD50-4BF7DB5C2184}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E739E3FE-D923-480A-9B01-3B2A623067E3}" ProjectSection(SolutionItems) = preProject LICENSE = LICENSE README.md = README.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples", "Examples\Examples.csproj", "{C089A10B-646D-407E-A2B8-848C6C522B13}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "duo_api_csharp.Examples", "duo_api_csharp.Examples\duo_api_csharp.Examples.csproj", "{C089A10B-646D-407E-A2B8-848C6C522B13}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "duo_api_csharp.Tests", "duo_api_csharp.Tests\duo_api_csharp.Tests.csproj", "{96DAEC7C-B28E-40D6-9764-CA3C467C1BD8}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -24,14 +24,14 @@ Global {6E96C9D9-0825-4D26-83C7-8A62180F8FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU {6E96C9D9-0825-4D26-83C7-8A62180F8FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU {6E96C9D9-0825-4D26-83C7-8A62180F8FB9}.Release|Any CPU.Build.0 = Release|Any CPU - {6B97B9FB-E553-494C-BD50-4BF7DB5C2184}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6B97B9FB-E553-494C-BD50-4BF7DB5C2184}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6B97B9FB-E553-494C-BD50-4BF7DB5C2184}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6B97B9FB-E553-494C-BD50-4BF7DB5C2184}.Release|Any CPU.Build.0 = Release|Any CPU {C089A10B-646D-407E-A2B8-848C6C522B13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C089A10B-646D-407E-A2B8-848C6C522B13}.Debug|Any CPU.Build.0 = Debug|Any CPU {C089A10B-646D-407E-A2B8-848C6C522B13}.Release|Any CPU.ActiveCfg = Release|Any CPU {C089A10B-646D-407E-A2B8-848C6C522B13}.Release|Any CPU.Build.0 = Release|Any CPU + {96DAEC7C-B28E-40D6-9764-CA3C467C1BD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96DAEC7C-B28E-40D6-9764-CA3C467C1BD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96DAEC7C-B28E-40D6-9764-CA3C467C1BD8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96DAEC7C-B28E-40D6-9764-CA3C467C1BD8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/duo_api_csharp/AssemblyInfo.cs b/duo_api_csharp/AssemblyInfo.cs deleted file mode 100644 index 75fbbb2..0000000 --- a/duo_api_csharp/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("duo_api_csharp")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("duo_api_csharp")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("b15c44a4-74d6-45b7-8a30-a313c2818083")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] - -// Allow tests to access internal methods for easier testing -[assembly: InternalsVisibleTo("DuoApiTest")] diff --git a/duo_api_csharp/CertificatePinnerFactory.cs b/duo_api_csharp/Classes/CertificatePinnerFactory.cs similarity index 54% rename from duo_api_csharp/CertificatePinnerFactory.cs rename to duo_api_csharp/Classes/CertificatePinnerFactory.cs index 8026eb1..d860ca3 100644 --- a/duo_api_csharp/CertificatePinnerFactory.cs +++ b/duo_api_csharp/Classes/CertificatePinnerFactory.cs @@ -1,57 +1,58 @@ /* - * Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates * All rights reserved + * https://github.com/duosecurity/duo_api_csharp */ -using System.IO; -using System.Linq; -using System.Net.Security; +using System.Text; using System.Reflection; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; -using System.Text; -using System; -namespace Duo +namespace duo_api_csharp.Classes { - public class CertificatePinnerFactory + /// + /// Create an instance of the CertificatePinner + /// For general use, use one of the static methods to create an instance rather than the constructur + /// + /// Certificates to validate against + public class CertificatePinnerFactory(X509CertificateCollection rootCerts) { - private readonly X509CertificateCollection _rootCerts; - - public CertificatePinnerFactory(X509CertificateCollection rootCerts) - { - _rootCerts = rootCerts; - } - + #region Public Static Methods /// /// Get a certificate pinner that ensures only connections to a specific list of root certificates are allowed /// /// A Duo certificate pinner for use in an HttpWebRequest public static RemoteCertificateValidationCallback GetDuoCertificatePinner() { - return new CertificatePinnerFactory(GetDuoCertCollection()).GetPinner(); + return new CertificatePinnerFactory(_GetDuoCertCollection())._GetPinner(); } + /// /// Get a certificate pinner that ensures only connections to the provided root certificates are allowed /// /// A certificate pinner for use in an HttpWebRequest public static RemoteCertificateValidationCallback GetCustomRootCertificatesPinner(X509CertificateCollection rootCerts) { - return new CertificatePinnerFactory(rootCerts).GetPinner(); + return new CertificatePinnerFactory(rootCerts)._GetPinner(); } - /// /// Get a certificate "pinner" that effectively disables SSL certificate validation /// /// public static RemoteCertificateValidationCallback GetCertificateDisabler() { - return (httpRequestMessage, certificate, chain, sslPolicyErrors) => true; + return (_, _, _, _) => true; } + #endregion Public Static Methods - internal RemoteCertificateValidationCallback GetPinner() + #region Internal Methods + internal RemoteCertificateValidationCallback _GetPinner() { - return PinCertificate; + // Return the delegate of our callback + // Calls from dotnet for validation for certificates will be handled by the delegate below + return _PinCertificateCallback; } /// @@ -64,79 +65,81 @@ internal RemoteCertificateValidationCallback GetPinner() /// The full certificate chain presented to the connection /// The current result of the certificate checks /// true if the connection should be allowed, false otherwise - internal bool PinCertificate(object request, - X509Certificate certificate, - X509Chain chain, - SslPolicyErrors sslPolicyErrors) + internal bool _PinCertificateCallback(object request, + X509Certificate? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) { // If there's no server certificate or chain, fail - if (certificate == null || chain == null) + if( certificate == null || chain == null ) { return false; } // If the regular certificate checking process failed, fail // we want everything to be valid, but then just restrict the acceptable root certificates - if (sslPolicyErrors != SslPolicyErrors.None) + if( sslPolicyErrors != SslPolicyErrors.None ) { return false; } // Double check everything's valid and grab the root certificate (and double check it's valid) - if (!chain.ChainStatus.All(status => status.Status == X509ChainStatusFlags.NoError)) + if( chain.ChainStatus.Any(status => status.Status != X509ChainStatusFlags.NoError) ) { return false; } + var chainLength = chain.ChainElements.Count; var rootCert = chain.ChainElements[chainLength - 1].Certificate; - if (!rootCert.Verify()) - { - return false; - } - + // Check that the root certificate is in the allowed list - if (!_rootCerts.Contains(rootCert)) - { - return false; - } - - return true; + return rootCert.Verify() && rootCerts.Contains(rootCert); } /// /// Get the root certificates allowed by Duo in a usable form /// /// A X509CertificateCollection of the allowed root certificates - internal static X509CertificateCollection GetDuoCertCollection() + internal static X509CertificateCollection _GetDuoCertCollection() { - var certs = ReadCertsFromFile(); - - X509CertificateCollection coll = new X509CertificateCollection(); - foreach (string oneCert in certs) + var certs = _ReadCertsFromFile(); + var coll = new X509CertificateCollection(); + foreach( var oneCert in certs ) { - if (!string.IsNullOrWhiteSpace(oneCert)) + if( !string.IsNullOrWhiteSpace(oneCert) ) { var bytes = Encoding.UTF8.GetBytes(oneCert); coll.Add(new X509Certificate(bytes)); } } + return coll; } /// /// Read the embedded Duo ca_certs.pem certificates file to get an array of certificate strings /// + /// The name of the resource in the assembly to retrieve /// The Duo root CA certificates as strings - internal static string[] ReadCertsFromFile() + internal static string[] _ReadCertsFromFile(string resource_name = "duo_api_csharp.Resources.ca_certs.pem") { - var certs = ""; - using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("duo_api_csharp.ca_certs.pem")) - using (StreamReader reader = new StreamReader(stream)) + try + { + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resource_name); + using var reader = (stream != null) ? new StreamReader(stream) : null; + if( reader != null ) + { + var certs = reader.ReadToEnd(); + return certs.Split(["-----DUO_CERT-----"], int.MaxValue, StringSplitOptions.None); + } + + throw new ApplicationException("Unable to read the embedded certificate file from the assembly. The read reqeuest returned null"); + } + catch( Exception Ex ) { - certs = reader.ReadToEnd(); + throw new ApplicationException($"Unable to read the embedded certificate file from the assembly. The read reqeuest returned {Ex.Message}", Ex); } - var splitOn = "-----DUO_CERT-----"; - return certs.Split(new string[] { splitOn }, int.MaxValue, StringSplitOptions.None); } + #endregion Internal Methods } } \ No newline at end of file diff --git a/duo_api_csharp/Classes/DuoBoolConverter.cs b/duo_api_csharp/Classes/DuoBoolConverter.cs new file mode 100644 index 0000000..1cc1f93 --- /dev/null +++ b/duo_api_csharp/Classes/DuoBoolConverter.cs @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; + +namespace duo_api_csharp.Classes +{ + /// + /// This exists because the Duo API is unable to accept anything other than strings + /// Hopefully, this limitation will be removed in a future version iteration + /// + internal class DuoBoolConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + var boolValue = (bool?)value; + if( boolValue == null ) return; + writer.WriteValue((bool)boolValue ? "true" : "false"); + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + return null; + } + + public override bool CanRead + { + get + { + return false; + } + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(bool); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Classes/DuoException.cs b/duo_api_csharp/Classes/DuoException.cs new file mode 100644 index 0000000..1e396fd --- /dev/null +++ b/duo_api_csharp/Classes/DuoException.cs @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using System.Net; + +namespace duo_api_csharp.Classes +{ + /// + /// Base exception thrown by the class library + /// + /// Error message + /// Any inner exception, if present + /// The HTTP status code if present + /// Request success result, if present + public class DuoException(string message, Exception? inner = null, HttpStatusCode? statusCode = null, bool? requestSuccess = null) : Exception(message, inner) + { + /// + /// The status code of the response + /// + public HttpStatusCode? StatusCode { get; } = statusCode; + + /// + /// If your request was successful or not + /// + public bool? RequestSuccess { get; } = requestSuccess; + } +} \ No newline at end of file diff --git a/duo_api_csharp/Classes/DuoModelBase.cs b/duo_api_csharp/Classes/DuoModelBase.cs new file mode 100644 index 0000000..a6afb8e --- /dev/null +++ b/duo_api_csharp/Classes/DuoModelBase.cs @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +namespace duo_api_csharp.Classes +{ + /// + /// Base class for + /// + public class DuoModelBase + { + /// + /// This method reverts a derived class back to its base class discarding any information + /// This is used when a API request is made with a Response model to a Request method. In this case + /// Serialising the model will send parameters that the server is not expecting and will result in a 400 + /// In this case, we discard any data not part of the request model to ensure the request succeeds + /// + /// The request model to reflect + /// The desired request model + /// If the Response model does not derive from the request model + internal object GetBaseClass(Type BaseType) + { + if( GetType() == BaseType ) return this; + if( !GetType().IsAssignableTo(BaseType) ) throw new ArgumentException("Object is not derived from BaseType"); + var newBaseClass = Activator.CreateInstance(BaseType); + if( newBaseClass != null ) + { + foreach( var Property in BaseType.GetProperties() ) + { + var getValue = Property.GetValue(this); + if( getValue != null ) Property.SetValue(newBaseClass, getValue); + } + + return newBaseClass; + } + + throw new ArgumentException("Failed to create class"); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Classes/Epoch.cs b/duo_api_csharp/Classes/Epoch.cs new file mode 100644 index 0000000..27442c9 --- /dev/null +++ b/duo_api_csharp/Classes/Epoch.cs @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +namespace duo_api_csharp.Classes +{ + internal class Epoch + { + private static readonly DateTime epochStart = new DateTime(1970, 1, 1, 0, 0, 0); + + public static DateTime? FromUnix(long? secondsSinceepoch) + { + if( secondsSinceepoch == null ) return null; + return DateTime.SpecifyKind(epochStart.AddSeconds((long)secondsSinceepoch), DateTimeKind.Utc); + } + + public static long? ToUnix(DateTime? dateTime) + { + if( dateTime == null ) return null; + return (long)((DateTime)dateTime - epochStart).TotalSeconds; + } + + public static long Now + { + get + { + return (long)(DateTime.UtcNow - epochStart).TotalSeconds; + } + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Duo.cs b/duo_api_csharp/Duo.cs index d047fec..c502e0d 100644 --- a/duo_api_csharp/Duo.cs +++ b/duo_api_csharp/Duo.cs @@ -1,821 +1,608 @@ /* - * Copyright (c) 2018 Duo Security + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates * All rights reserved + * https://github.com/duosecurity/duo_api_csharp */ -using System; -using System.Configuration; -using System.Collections.Generic; -using System.IO; using System.Net; -using System.Security.Cryptography; -using System.Text.RegularExpressions; -using System.Text; using System.Web; -using System.Globalization; -using System.Linq; -using System.Runtime.InteropServices; -using System.Security.Cryptography.X509Certificates; +using System.Text; +using Newtonsoft.Json; using System.Net.Security; -using System.Text.Json; -using Duo.Extensions; - -namespace Duo +using duo_api_csharp.Models; +using duo_api_csharp.Classes; +using System.Net.Http.Headers; +using duo_api_csharp.Endpoints; +using duo_api_csharp.SignatureTypes; +using System.Security.Cryptography.X509Certificates; + +namespace duo_api_csharp { - public class DuoApi + /// + /// Duo API class + /// + public class DuoAPI { - public string DEFAULT_AGENT = "DuoAPICSharp/1.0"; - - private const int INITIAL_BACKOFF_MS = 1000; - private const int MAX_BACKOFF_MS = 32000; - private const int BACKOFF_FACTOR = 2; - private const int RATE_LIMIT_HTTP_CODE = 429; - private string ikey; - private string skey; - private string host; - private string url_scheme; - private string user_agent; - private SleepService sleepService; - private RandomService randomService; - private bool sslCertValidation = true; - private X509CertificateCollection customRoots = null; + private bool _TLSCertificateValidation = true; + private readonly string user_agent; + private readonly string ikey; + private readonly string skey; + private readonly string host; - // TLS 1.0/1.1 deprecation effective June 30, 2023 - // Of the SecurityProtocolType enum, it should be noted that SystemDefault is not available prior to .NET 4.7 and TLS 1.3 is not available prior to .NET 4.8. - private static SecurityProtocolType SelectSecurityProtocolType - { - get - { - SecurityProtocolType t; - if (!Enum.TryParse(ConfigurationManager.AppSettings["DuoAPI_SecurityProtocolType"], out t)) - return SecurityProtocolType.Tls12; - - return t; - } - } - - /// Duo integration key - /// Duo secret key - /// Application secret key - public DuoApi(string ikey, string skey, string host) - : this(ikey, skey, host, null) - { - } - + #region Constructor + /// + /// Create a new instance of the Duo API class + /// /// Duo integration key /// Duo secret key - /// Application secret key - /// HTTP client User-Agent - public DuoApi(string ikey, string skey, string host, string user_agent) - : this(ikey, skey, host, user_agent, "https", new ThreadSleepService(), new SystemRandomService()) - { - } - - protected DuoApi(string ikey, string skey, string host, string user_agent, string url_scheme, - SleepService sleepService, RandomService randomService) + /// API URL to communicate with + /// Useragent to send to the API + public DuoAPI(string ikey, string skey, string host, string user_agent = "Duo API CSharp/2.0") { this.ikey = ikey; this.skey = skey; this.host = host; - this.url_scheme = url_scheme; - this.sleepService = sleepService; - this.randomService = randomService; - if (String.IsNullOrEmpty(user_agent)) - { - this.user_agent = FormatUserAgent(DEFAULT_AGENT); - } - else - { - this.user_agent = user_agent; - } + this.user_agent = user_agent; + Admin_v1 = new AdminAPIv1(this); + Admin_v2 = new AdminAPIv2(this); } + #endregion Constructor + #region Public Properties /// /// Disables SSL certificate validation for the API calls the client makes. /// Incomptible with UseCustomRootCertificates since certificates will not be checked. - /// - /// THIS SHOULD NEVER BE USED IN A PRODUCTION ENVIRONMENT + /// Only available in debug builds /// - /// The DuoApi - public DuoApi DisableSslCertificateValidation() + public bool DisableTLSCertificateValidation { - sslCertValidation = false; - return this; + get + { + return _TLSCertificateValidation; + } + set + { + #if DEBUG + _TLSCertificateValidation = value; + #else + throw new Exception("Disabling TLS validation is not available in release builds"); + #endif + } } /// /// Override the set of Duo root certificates used for certificate pinning. Provide a collection of acceptable root certificates. - /// /// Incompatible with DisableSslCertificateValidation - if that is enabled, certificate pinning is not done at all. /// - /// The custom set of root certificates to trust - /// The DuoApi - public DuoApi UseCustomRootCertificates(X509CertificateCollection customRoots) - { - this.customRoots = customRoots; - return this; - } - - public static string FinishCanonicalize(string p) - { - // Signatures require upper-case hex digits. - p = Regex.Replace(p, - "(%[0-9A-Fa-f][0-9A-Fa-f])", - c => c.Value.ToUpperInvariant()); - // Escape only the expected characters. - p = Regex.Replace(p, - "([!'()*])", - c => "%" + Convert.ToByte(c.Value[0]).ToString("X")); - p = p.Replace("%7E", "~"); - // UrlEncode converts space (" ") to "+". The - // signature algorithm requires "%20" instead. Actual - // + has already been replaced with %2B. - p = p.Replace("+", "%20"); - return p; - } - - public static string CanonicalizeParams(Dictionary parameters) - { - var ret = new List(); - foreach (KeyValuePair pair in parameters) + public X509CertificateCollection? CustomRootCertificates { get; set; } = null; + + /// + /// Time, after which elapsed we consider the API request to have failed and return an error response + /// If null, the default system RequestTimeout is used + /// + public TimeSpan? RequestTimeout { get; set; } = null; + + /// + /// Admin API interface (version 1) + /// + public AdminAPIv1 Admin_v1 { get; init; } + + /// + /// Admin API interface (version 2) + /// + public AdminAPIv2 Admin_v2 { get; init; } + #endregion Public Properties + + #region Public Methods + /// + /// Perform a Duo API call, disregarding response data other than success state + /// To return response data, specify a model to deserialise the response into with T + /// + /// HTTP Method to + /// The API path, excluding the host + /// Parameters that make up the request + /// The current date and time, used to authenticate + /// the API request. Typically, you should specify DateTime.UtcNow, + /// but if you do not wish to rely on the system-wide clock, you may + /// determine the current date/time by some other means. + /// The type of signature to use to sign the request + /// Response model indicating status code and response data, if any + /// Exception on unexpected error that could not be returned as part of the response model + public DuoAPIResponse APICall( + HttpMethod method, + string path, + DuoRequestData? param = null, + DateTime? date = null, + DuoSignatureTypes signatureType = DuoSignatureTypes.Duo_SignatureTypeV5) + { + // Get request date + var requestDate = date ?? DateTime.UtcNow; + var serverRequestUri = new UriBuilder { - string p = String.Format("{0}={1}", - HttpUtility.UrlEncode(pair.Key), - HttpUtility.UrlEncode(pair.Value)); - - p = FinishCanonicalize(p); - ret.Add(p); - } - ret.Sort(StringComparer.Ordinal); - return string.Join("&", ret.ToArray()); - } - + Scheme = "https", + Host = host, + Path = path, + Port = -1 + }; - // handle value as an object eg. next_offset = ["123", "fdajkld"] - public static string CanonicalizeParams(Dictionary parameters) - { - var ret = new List(); - foreach (KeyValuePair pair in parameters) + // Check signature + IDuoSignatureTypes DuoSignature; + if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV2 ) DuoSignature = new DuoSignatureV2(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV4 ) DuoSignature = new DuoSignatureV4(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV5 ) DuoSignature = new DuoSignatureV5(ikey, skey, host, requestDate); + else throw new DuoException("Invalid or unsupported signature type"); + + // Except for POST,PUT and PATCH, put parameters in the URL + if( method != HttpMethod.Post && method != HttpMethod.Put && method != HttpMethod.Patch && param is DuoParamRequestData paramData ) { - string p = ""; - if (pair.Value.GetType() == typeof(string[])) + var queryBuilder = new StringBuilder(); + foreach( var (paramKey, paramValue) in paramData.RequestData ) { - string[] values = (string[])pair.Value; - string value1 = values[0]; - string value2 = values[1]; - p = String.Format("{0}={1}&{2}={3}", - HttpUtility.UrlEncode(pair.Key), - HttpUtility.UrlEncode(value1), - HttpUtility.UrlEncode(pair.Key), - HttpUtility.UrlEncode(value2)); + if( queryBuilder.Length != 0 ) queryBuilder.Append('&'); + queryBuilder.Append($"{HttpUtility.UrlEncode(paramKey)}={HttpUtility.UrlEncode(paramValue)}"); } - else - { - string val = (string)pair.Value; - p = String.Format("{0}={1}", - HttpUtility.UrlEncode(pair.Key), - HttpUtility.UrlEncode(val)); - } - p = FinishCanonicalize(p); - ret.Add(p); + serverRequestUri.Query = queryBuilder.ToString(); } - ret.Sort(StringComparer.Ordinal); - return string.Join("&", ret.ToArray()); - } - - - protected string CanonicalizeRequest(string method, - string path, - string canon_params, - string date) - { - string[] lines = { - date, - method.ToUpperInvariant(), - this.host.ToLower(), - path, - canon_params, + else if( method != HttpMethod.Post && method != HttpMethod.Put && method != HttpMethod.Patch && param is DuoJsonRequestData ) + { + throw new DuoException("DuoJsonRequestData provided and HttpMethod != Post|Put|Patch. This is unsupported!"); + } + + // Get request auth and send + var requestHeaders = DuoSignature.DefaultRequestHeaders; + var requestAuthentication = DuoSignature.SignRequest(method, path, requestDate, param, requestHeaders); + var clientResponse = _SendHTTPRequest(method, serverRequestUri.Uri, requestAuthentication, signatureType, param, requestHeaders); + var responseObject = new DuoAPIResponse + { + RequestSuccess = clientResponse.IsSuccessStatusCode, + StatusCode = clientResponse.StatusCode }; - string canon = String.Join("\n", - lines); - return canon; - } - - public string Sign(string method, - string path, - string canon_params, - string date) - { - string canon = this.CanonicalizeRequest(method, - path, - canon_params, - date); - string sig = this.HmacSign(canon); - string auth = this.ikey + ':' + sig; - return "Basic " + DuoApi.Encode64(auth); - } - - public string ApiCall(string method, - string path, - Dictionary parameters) - { - HttpStatusCode statusCode; - return ApiCall(method, path, parameters, 0, DateTime.UtcNow, out statusCode); - } - - /// The request timeout, in milliseconds. - /// Specify 0 to use the system-default timeout. Use caution if - /// you choose to specify a custom timeout - some API - /// calls (particularly in the Auth APIs) will not - /// return a response until an out-of-band authentication process - /// has completed. In some cases, this may take as much as a - /// small number of minutes. - public string ApiCall(string method, - string path, - Dictionary parameters, - int timeout, - out HttpStatusCode statusCode) - { - return ApiCall(method, path, parameters, 0, DateTime.UtcNow, out statusCode); + + // Try to read data from response + try + { + using var reader = new StreamReader(clientResponse.Content.ReadAsStream()); + responseObject.RawResponseData = reader.ReadToEnd(); + responseObject.ResponseData = JsonConvert.DeserializeObject(responseObject.RawResponseData); + } + catch( Exception Ex ) + { + throw new DuoException("A deserialisation error has occoured. See the inner exception for more details", Ex, clientResponse.StatusCode, clientResponse.IsSuccessStatusCode); + } + + return responseObject; } - + + /// + /// Perform a Duo API call, disregarding response data other than success state + /// To return response data, specify a model to deserialise the response into with T + /// + /// HTTP Method to + /// The API path, excluding the host + /// Parameters that make up the request /// The current date and time, used to authenticate /// the API request. Typically, you should specify DateTime.UtcNow, /// but if you do not wish to rely on the system-wide clock, you may /// determine the current date/time by some other means. - /// The request timeout, in milliseconds. - /// Specify 0 to use the system-default timeout. Use caution if - /// you choose to specify a custom timeout - some API - /// calls (particularly in the Auth APIs) will not - /// return a response until an out-of-band authentication process - /// has completed. In some cases, this may take as much as a - /// small number of minutes. - public string ApiCall(string method, - string path, - Dictionary parameters, - int timeout, - DateTime date, - out HttpStatusCode statusCode) - { - string canon_params = DuoApi.CanonicalizeParams(parameters); - string query = ""; - if (!method.Equals("POST") && !method.Equals("PUT")) + /// The type of signature to use to sign the request + /// Response model indicating status code and response data, if any + /// Exception on unexpected error that could not be returned as part of the response model + public async Task APICallAsync( + HttpMethod method, + string path, + DuoRequestData? param = null, + DateTime? date = null, + DuoSignatureTypes signatureType = DuoSignatureTypes.Duo_SignatureTypeV5) + { + // Get request date + var requestDate = date ?? DateTime.UtcNow; + var serverRequestUri = new UriBuilder { - if (parameters.Count > 0) - { - query = "?" + canon_params; - } - } - string url = string.Format("{0}://{1}{2}{3}", - this.url_scheme, - this.host, - path, - query); - - string date_string = DuoApi.DateToRFC822(date); - string auth = this.Sign(method, path, canon_params, date_string); - - - - HttpWebResponse response = AttemptRetriableHttpRequest( - method, url, auth, date_string, canon_params, timeout); - StreamReader reader - = new StreamReader(response.GetResponseStream()); - statusCode = response.StatusCode; - return reader.ReadToEnd(); - } + Scheme = "https", + Host = host, + Path = path, + Port = -1 + }; - private HttpWebRequest PrepareHttpRequest(String method, String url, String auth, String date, - String cannonParams, int timeout) - { - ServicePointManager.SecurityProtocol = SelectSecurityProtocolType; + // Check signature + IDuoSignatureTypes DuoSignature; + if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV2 ) DuoSignature = new DuoSignatureV2(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV4 ) DuoSignature = new DuoSignatureV4(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV5 ) DuoSignature = new DuoSignatureV5(ikey, skey, host, requestDate); + else throw new DuoException("Invalid or unsupported signature type"); - HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); - request.ServerCertificateValidationCallback = GetCertificatePinner(); - request.Method = method; - request.Accept = "application/json"; - request.Headers.Add("Authorization", auth); - request.Headers.Add("X-Duo-Date", date); - request.UserAgent = this.user_agent; - // If no proxy, check for and use WinHTTP proxy as autoconfig won't pick this up when run from a service - if (!HasProxyServer(request)) - request.Proxy = GetWinhttpProxy(); - - if (method.Equals("POST") || method.Equals("PUT")) + // Except for POST,PUT and PATCH, put parameters in the URL + if( method != HttpMethod.Post && method != HttpMethod.Put && method != HttpMethod.Patch && param is DuoParamRequestData paramData ) { - byte[] data = Encoding.UTF8.GetBytes(cannonParams); - request.ContentType = "application/x-www-form-urlencoded"; - request.ContentLength = data.Length; - using (Stream requestStream = request.GetRequestStream()) + var queryBuilder = new StringBuilder(); + foreach( var (paramKey, paramValue) in paramData.RequestData ) { - requestStream.Write(data, 0, data.Length); + if( queryBuilder.Length != 0 ) queryBuilder.Append('&'); + queryBuilder.Append($"{HttpUtility.UrlEncode(paramKey)}={HttpUtility.UrlEncode(paramValue)}"); } + serverRequestUri.Query = queryBuilder.ToString(); } - if (timeout > 0) + else if( method != HttpMethod.Post && method != HttpMethod.Put && method != HttpMethod.Patch && param is DuoJsonRequestData ) { - request.Timeout = timeout; + throw new DuoException("DuoJsonRequestData provided and HttpMethod != Post|Put|Patch. This is unsupported!"); } - - return request; - } - - private RemoteCertificateValidationCallback GetCertificatePinner() - { - if (!sslCertValidation) + + // Get request auth and send + var requestHeaders = DuoSignature.DefaultRequestHeaders; + var requestAuthentication = DuoSignature.SignRequest(method, path, requestDate, param, requestHeaders); + var clientResponse = await _SendHTTPRequestAsync(method, serverRequestUri.Uri, requestAuthentication, signatureType, param, requestHeaders); + var responseObject = new DuoAPIResponse { - // Pinner that effectively disables cert pinning by always returning true - return CertificatePinnerFactory.GetCertificateDisabler(); - } - - if (customRoots != null) + RequestSuccess = clientResponse.IsSuccessStatusCode, + StatusCode = clientResponse.StatusCode + }; + + // Try to read data from response + try { - return CertificatePinnerFactory.GetCustomRootCertificatesPinner(customRoots); + responseObject.RawResponseData = await clientResponse.Content.ReadAsStringAsync(); + responseObject.ResponseData = JsonConvert.DeserializeObject(responseObject.RawResponseData); } - - return CertificatePinnerFactory.GetDuoCertificatePinner(); - } - - private HttpWebResponse AttemptRetriableHttpRequest( - String method, String url, String auth, String date, String cannonParams, int timeout) - { - int backoffMs = INITIAL_BACKOFF_MS; - while (true) + catch( Exception Ex ) { - // Do the request and process the result. - HttpWebRequest request = PrepareHttpRequest(method, url, auth, date, cannonParams, timeout); - HttpWebResponse response; - try - { - response = (HttpWebResponse)request.GetResponse(); - } - catch (WebException ex) - { - response = (HttpWebResponse)ex.Response; - if (response == null) - { - throw; - } - } - - if (response.StatusCode != (HttpStatusCode)RATE_LIMIT_HTTP_CODE || backoffMs > MAX_BACKOFF_MS) - { - return response; - } - - sleepService.Sleep(backoffMs + randomService.GetInt(1001)); - backoffMs *= BACKOFF_FACTOR; + throw new DuoException("A deserialisation error has occoured. See the inner exception for more details", Ex, clientResponse.StatusCode, clientResponse.IsSuccessStatusCode); } + + return responseObject; } - + + /// + /// Perform a Duo API call, disregarding response data other than success state + /// To return response data, specify a model to deserialise the response into with T + /// + /// HTTP Method to + /// The API path, excluding the host + /// Parameters that make up the request /// The current date and time, used to authenticate /// the API request. Typically, you should specify DateTime.UtcNow, /// but if you do not wish to rely on the system-wide clock, you may /// determine the current date/time by some other means. - /// The request timeout, in milliseconds. - /// Specify 0 to use the system-default timeout. Use caution if - /// you choose to specify a custom timeout - some API - /// calls (particularly in the Auth APIs) will not - /// return a complete JSON response. - /// raises if JSON response indicates an error - private Dictionary BaseJSONApiCall(string method, - string path, - Dictionary parameters, - int timeout, - DateTime date) - { - HttpStatusCode statusCode; - string res = this.ApiCall(method, path, parameters, timeout, date, out statusCode); + /// The type of signature to use to sign the request + /// Response model indicating status code and response data, if any + /// Exception on unexpected error that could not be returned as part of the response model + public DuoAPIResponse APICall( + HttpMethod method, + string path, + DuoRequestData? param = null, + DateTime? date = null, + DuoSignatureTypes signatureType = DuoSignatureTypes.Duo_SignatureTypeV5) + { + // Get request date + var requestDate = date ?? DateTime.UtcNow; + var serverRequestUri = new UriBuilder + { + Scheme = "https", + Host = host, + Path = path, + Port = -1 + }; - try + // Check signature + IDuoSignatureTypes DuoSignature; + if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV2 ) DuoSignature = new DuoSignatureV2(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV4 ) DuoSignature = new DuoSignatureV4(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV5 ) DuoSignature = new DuoSignatureV5(ikey, skey, host, requestDate); + else throw new DuoException("Invalid or unsupported signature type"); + + // Except for POST,PUT and PATCH, put parameters in the URL + if( method != HttpMethod.Post && method != HttpMethod.Put && method != HttpMethod.Patch && param is DuoParamRequestData paramData ) { - var dict = DeserializeJsonToMixedDictionary(res); - if (dict["stat"] as string == "OK") - { - return dict; - } - else + var queryBuilder = new StringBuilder(); + foreach( var (paramKey, paramValue) in paramData.RequestData ) { - int? check = dict["code"] as int?; - int code; - if (check.HasValue) - { - code = check.Value; - } - else - { - code = 0; - } - String message_detail = ""; - if (dict.ContainsKey("message_detail")) - { - message_detail = dict["message_detail"] as string; - } - throw new ApiException(code, - (int)statusCode, - dict["message"] as string, - message_detail); + if( queryBuilder.Length != 0 ) queryBuilder.Append('&'); + queryBuilder.Append($"{HttpUtility.UrlEncode(paramKey)}={HttpUtility.UrlEncode(paramValue)}"); } + serverRequestUri.Query = queryBuilder.ToString(); } - catch (ApiException) + + // Get request auth and send + var requestHeaders = DuoSignature.DefaultRequestHeaders; + var requestAuthentication = DuoSignature.SignRequest(method, path, requestDate, param, requestHeaders); + var clientResponse = _SendHTTPRequest(method, serverRequestUri.Uri, requestAuthentication, signatureType, param, requestHeaders); + var responseObject = new DuoAPIResponse { - throw; - } - catch (Exception e) + RequestSuccess = clientResponse.IsSuccessStatusCode, + StatusCode = clientResponse.StatusCode + }; + + // Try to read data from response + try { - throw new BadResponseException((int)statusCode, e); + using var reader = new StreamReader(clientResponse.Content.ReadAsStream()); + responseObject.ResponseData = JsonConvert.DeserializeObject>(reader.ReadToEnd()); } - } - - private Dictionary DeserializeJsonToMixedDictionary(string json) - { - var sourceDict = JsonSerializer.Deserialize - >(json); - var targetDict = new Dictionary(); - foreach (var kvp in sourceDict) + catch( Exception Ex ) { - targetDict.Add(kvp.Key, kvp.Value.ConvertToObject()); + throw new DuoException("A deserialisation error has occoured. See the inner exception for more details", Ex, clientResponse.StatusCode, clientResponse.IsSuccessStatusCode); } - return targetDict; - } - - public T JSONApiCall(string method, - string path, - Dictionary parameters) - where T : class - { - return JSONApiCall(method, path, parameters, 0, DateTime.UtcNow); - } - - /// The request timeout, in milliseconds. - /// Specify 0 to use the system-default timeout. Use caution if - /// you choose to specify a custom timeout - some API - /// calls (particularly in the Auth APIs) will not - /// return a response until an out-of-band authentication process - /// has completed. In some cases, this may take as much as a - /// small number of minutes. - public T JSONApiCall(string method, - string path, - Dictionary parameters, - int timeout) - where T : class - { - return JSONApiCall(method, path, parameters, timeout, DateTime.UtcNow); - } - - /// The current date and time, used to authenticate - /// the API request. Typically, you should specify DateTime.UtcNow, - /// but if you do not wish to rely on the system-wide clock, you may - /// determine the current date/time by some other means. - /// The request timeout, in milliseconds. - /// Specify 0 to use the system-default timeout. Use caution if - /// you choose to specify a custom timeout - some API - /// calls (particularly in the Auth APIs) will not - /// return a response until an out-of-band authentication process - /// has completed. In some cases, this may take as much as a - /// small number of minutes. - public T JSONApiCall(string method, - string path, - Dictionary parameters, - int timeout, - DateTime date) - where T : class - { - var dict = BaseJSONApiCall(method, path, parameters, timeout, date); - return dict["response"] as T; - } - - public Dictionary JSONPagingApiCall(string method, - string path, - Dictionary parameters, - int offset, - int limit) - { - return JSONPagingApiCall(method, path, parameters, offset, limit, 0, DateTime.UtcNow); + + return responseObject; } - + + /// + /// Perform a Duo API call, disregarding response data other than success state + /// To return response data, specify a model to deserialise the response into with T + /// + /// HTTP Method to + /// The API path, excluding the host + /// Parameters that make up the request /// The current date and time, used to authenticate /// the API request. Typically, you should specify DateTime.UtcNow, /// but if you do not wish to rely on the system-wide clock, you may /// determine the current date/time by some other means. - /// The request timeout, in milliseconds. - /// Specify 0 to use the system-default timeout. Use caution if - /// you choose to specify a custom timeout - some API - /// calls (particularly in the Auth APIs) will not - /// return a response until an out-of-band authentication process - /// has completed. In some cases, this may take as much as a - /// small number of minutes. - /// The offset of the first record in the next - /// page of results within the total result set. - /// The number of records you would like returned - /// with this request. The service is free to return a different - /// number. You should use the 'next_offset' from the returned - /// metadata to pick the offset for the next page. - /// return a JSON dictionary with top level keys: stat, response, metadata. - /// The actual requested data is in 'response'. 'metadata' contains a - /// 'next_offset' key which should be used to fetch the next page. - public Dictionary JSONPagingApiCall(string method, - string path, - Dictionary parameters, - int offset, - int limit, - int timeout, - DateTime date) - { - // copy parameters so we don't cause any side-effects - parameters = new Dictionary(parameters); - parameters["offset"] = offset.ToString(); // overrides caller value - parameters["limit"] = limit.ToString(); - - return this.BaseJSONApiCall(method, path, parameters, timeout, date); - } - - - /// Helper to format a User-Agent string with some information about - /// the operating system / .NET runtime - /// e.g. "FooClient/1.0" - public static string FormatUserAgent(string product_name) - { - return String.Format( - "{0} ({1}; .NET {2})", product_name, System.Environment.OSVersion, - System.Environment.Version); - } - - #region Private Methods - private string HmacSign(string data) - { - byte[] key_bytes = ASCIIEncoding.ASCII.GetBytes(this.skey); - HMACSHA512 hmac = new HMACSHA512(key_bytes); - - byte[] data_bytes = ASCIIEncoding.ASCII.GetBytes(data); - hmac.ComputeHash(data_bytes); - - string hex = BitConverter.ToString(hmac.Hash); - return hex.Replace("-", "").ToLower(); - } - - private static string Encode64(string plaintext) - { - byte[] plaintext_bytes = ASCIIEncoding.ASCII.GetBytes(plaintext); - string encoded = System.Convert.ToBase64String(plaintext_bytes); - return encoded; - } + /// The type of signature to use to sign the request + /// Response model indicating status code and response data, if any + /// Exception on unexpected error that could not be returned as part of the response model + public async Task> APICallAsync( + HttpMethod method, + string path, + DuoRequestData? param = null, + DateTime? date = null, + DuoSignatureTypes signatureType = DuoSignatureTypes.Duo_SignatureTypeV5) + { + // Get request date + var requestDate = date ?? DateTime.UtcNow; + var serverRequestUri = new UriBuilder + { + Scheme = "https", + Host = host, + Path = path, + Port = -1 + }; - private static string DateToRFC822(DateTime date) - { - // Can't use the "zzzz" format because it adds a ":" - // between the offset's hours and minutes. - string date_string = date.ToString( - "ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture); - int offset = 0; - // set offset if input date is not UTC time. - if (date.Kind != DateTimeKind.Utc) - { - offset = TimeZoneInfo.Local.GetUtcOffset(date).Hours; + // Check signature + IDuoSignatureTypes DuoSignature; + if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV2 ) DuoSignature = new DuoSignatureV2(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV4 ) DuoSignature = new DuoSignatureV4(ikey, skey, host, requestDate); + else if( signatureType == DuoSignatureTypes.Duo_SignatureTypeV5 ) DuoSignature = new DuoSignatureV5(ikey, skey, host, requestDate); + else throw new DuoException("Invalid or unsupported signature type"); + + // Except for POST,PUT and PATCH, put parameters in the URL + if( method != HttpMethod.Post && method != HttpMethod.Put && method != HttpMethod.Patch && param is DuoParamRequestData paramData ) + { + var queryBuilder = new StringBuilder(); + foreach( var (paramKey, paramValue) in paramData.RequestData ) + { + if( queryBuilder.Length != 0 ) queryBuilder.Append('&'); + queryBuilder.Append($"{HttpUtility.UrlEncode(paramKey)}={HttpUtility.UrlEncode(paramValue)}"); + } + serverRequestUri.Query = queryBuilder.ToString(); } - string zone; - // + or -, then 0-pad, then offset, then more 0-padding. - if (offset < 0) + + // Get request auth and send + var requestHeaders = DuoSignature.DefaultRequestHeaders; + var requestAuthentication = DuoSignature.SignRequest(method, path, requestDate, param, requestHeaders); + var clientResponse = await _SendHTTPRequestAsync(method, serverRequestUri.Uri, requestAuthentication, signatureType, param, requestHeaders); + var responseObject = new DuoAPIResponse + { + RequestSuccess = clientResponse.IsSuccessStatusCode, + StatusCode = clientResponse.StatusCode + }; + + // Try to read data from response + try { - offset *= -1; - zone = "-"; + responseObject.ResponseData = JsonConvert.DeserializeObject>(await clientResponse.Content.ReadAsStringAsync()); } - else + catch( Exception Ex ) { - zone = "+"; + throw new DuoException("A deserialisation error has occoured. See the inner exception for more details", Ex, clientResponse.StatusCode, clientResponse.IsSuccessStatusCode); } - zone += offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0'); - date_string += " " + zone.PadRight(5, '0'); - return date_string; + + return responseObject; } + #endregion Public Methods + #region Private Methods /// - /// Gets the WinHTTP proxy. + /// Send a HTTP request to the Duo Servers /// - /// - /// Normally, C# picks up these proxy settings by default, but when run under the SYSTEM account, it does not. - /// - /// - private static System.Net.WebProxy GetWinhttpProxy() - { - string[] proxyServerNames = null; - string primaryProxyServer = null; - string[] bypassHostnames = null; - bool enableLocalBypass = false; - System.Net.WebProxy winhttpProxy = null; - - // Has a proxy been configured? - // No. Is a WinHTTP proxy set? - int internetHandle = WinHttpOpen("DuoTest", WinHttp_Access_Type.WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, null, null, 0); - if (internetHandle != 0) - { - // Yes, use it. This is normal when run under the SYSTEM account and a WinHTTP proxy is configured. When run as a normal user, - // the Proxy property will already be configured correctly. To resolve this for SYSTEM, manually read proxy settings and configure. - var proxyInfo = new WINHTTP_PROXY_INFO(); - WinHttpGetDefaultProxyConfiguration(proxyInfo); - if (proxyInfo.lpszProxy != null) + /// HTTP Request method + /// The URL, excluding the host + /// The generated bearer auth token + /// The type of signature to use to sign the request + /// Request data to be placed in the request body + /// Any additional headers to send with the request + /// HttpResponseMessage + /// If no data is provided for PUT or POST methods + /// Error on HttpRequest from dotnet + private HttpResponseMessage _SendHTTPRequest(HttpMethod method, Uri url, string auth, DuoSignatureTypes signatureType, DuoRequestData? bodyRequestData = null, Dictionary? duoHeaders = null) + { + // Setup the HttpClient. There is no reason to permit anything other than TLS1.2 and 1.3 as the API endpoints don't accept it + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls13 | SecurityProtocolType.Tls12; + var WebRequest = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = _ServerCertificateCustomValidationCallback, + }); + + // Setup the request headers + WebRequest.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", auth); + WebRequest.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + WebRequest.DefaultRequestHeaders.UserAgent.ParseAdd(_FormatUserAgent(user_agent)); + WebRequest.Timeout = RequestTimeout ?? WebRequest.Timeout; + + // Add additional headers + if( duoHeaders != null ) + { + foreach( var (header, value) in duoHeaders ) + { + WebRequest.DefaultRequestHeaders.Add(header, value); + } + } + + // Formulate the request message + var requestMessage = new HttpRequestMessage + { + Method = method, + RequestUri = url + }; + + // Handle request data + if( method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch ) + { + if( signatureType is DuoSignatureTypes.Duo_SignatureTypeV4 or DuoSignatureTypes.Duo_SignatureTypeV5 ) { - if (proxyInfo.lpszProxy != null) + // requestData is JSON in the request body for Signature 4 and 5 + if( bodyRequestData is DuoJsonRequestData jsonData && !string.IsNullOrEmpty(jsonData.RequestData) ) + { + requestMessage.Content = new StringContent(jsonData.RequestData, Encoding.UTF8, jsonData.ContentTypeHeader); + } + else if( bodyRequestData is DuoJsonRequestDataObject { RequestData: not null } jsonDataWithObject ) + { + var jsonFormattingSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; + + requestMessage.Content = new StringContent(JsonConvert.SerializeObject(jsonDataWithObject.RequestData, jsonFormattingSettings), Encoding.UTF8, jsonDataWithObject.ContentTypeHeader); + } + else if( bodyRequestData is DuoParamRequestData paramData ) { - proxyServerNames = proxyInfo.lpszProxy.Split(new char[] { ' ', '\t', ';' }); - if ((proxyServerNames == null) || (proxyServerNames.Length == 0)) - primaryProxyServer = proxyInfo.lpszProxy; - else - primaryProxyServer = proxyServerNames[0]; + requestMessage.Content = new FormUrlEncodedContent(paramData.RequestData); } - if (proxyInfo.lpszProxyBypass != null) + else { - bypassHostnames = proxyInfo.lpszProxyBypass.Split(new char[] { ' ', '\t', ';' }); - if ((bypassHostnames == null) || (bypassHostnames.Length == 0)) - bypassHostnames = new string[] { proxyInfo.lpszProxyBypass }; - if (bypassHostnames != null) - enableLocalBypass = bypassHostnames.Contains("local", StringComparer.InvariantCultureIgnoreCase); + throw new DuoException($"{method} specified but either DuoJsonRequestData was not provided or DuoJsonRequestData.RequestData was null or empty (And signaturetype was >=4)"); + } + } + else + { + // requestData is FormUrl encoded in the request body for Signature 2 + if( bodyRequestData is DuoParamRequestData paramData ) + { + requestMessage.Content = new FormUrlEncodedContent(paramData.RequestData); + } + else + { + throw new DuoException($"{method} specified but either DuoParamRequestData was not provided or DuoParamRequestData.RequestData was null (And signaturetype was <4)"); } - if (primaryProxyServer != null) - winhttpProxy = new System.Net.WebProxy(proxyServerNames[0], enableLocalBypass, bypassHostnames); } - WinHttpCloseHandle(internetHandle); - internetHandle = 0; - } - else - { - throw new Exception(String.Format("WinHttp init failed {0}", System.Runtime.InteropServices.Marshal.GetLastWin32Error())); } - - return winhttpProxy; + + // Make request and send response back to parent + return WebRequest.Send(requestMessage); } - + /// - /// Determines if the specified web request is using a proxy server. + /// Send a async HTTP request to the Duo Servers /// - /// - /// If no proxy is set, the Proxy member is typically non-null and set to an object type that includes but hides IWebProxy with no address, - /// so it cannot be inspected. Resolving this requires reflection to extract the hidden webProxy object and check it's Address member. - /// - /// Request to check - /// TRUE if a proxy is in use, else FALSE - public static bool HasProxyServer(HttpWebRequest requestObject) - { - WebProxy actualProxy = null; - bool hasProxyServer = false; - - if (requestObject.Proxy != null) + /// HTTP Request method + /// The URL, excluding the host + /// The generated bearer auth token + /// The type of signature to use to sign the request + /// Request data to be placed in the request body + /// Any additional headers to send with the request + /// HttpResponseMessage + /// If no data is provided for PUT or POST methods + /// Error on HttpRequest from dotnet + private async Task _SendHTTPRequestAsync(HttpMethod method, Uri url, string auth, DuoSignatureTypes signatureType, DuoRequestData? bodyRequestData = null, Dictionary? duoHeaders = null) + { + // Setup the HttpClient. There is no reason to permit anything other than TLS1.2 and 1.3 as the API endpoints don't accept it + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls13 | SecurityProtocolType.Tls12; + var WebRequest = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = _ServerCertificateCustomValidationCallback, + }); + + // Setup the request headers + WebRequest.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", auth); + WebRequest.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + WebRequest.DefaultRequestHeaders.UserAgent.ParseAdd(_FormatUserAgent(user_agent)); + WebRequest.Timeout = RequestTimeout ?? WebRequest.Timeout; + + // Add additional headers + if( duoHeaders != null ) + { + foreach( var (header, value) in duoHeaders ) + { + WebRequest.DefaultRequestHeaders.Add(header, value); + } + } + + // Formulate the request message + var requestMessage = new HttpRequestMessage { - // WebProxy is described as the base class for IWebProxy, so we should always see this type as the field is initialized by the framework. - if (!(requestObject.Proxy is WebProxy)) + Method = method, + RequestUri = url + }; + + // Handle request data + if( method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch ) + { + if( signatureType is DuoSignatureTypes.Duo_SignatureTypeV4 or DuoSignatureTypes.Duo_SignatureTypeV5 ) { - var webProxyField = requestObject.Proxy.GetType().GetField("webProxy", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); - if (webProxyField != null) - actualProxy = webProxyField.GetValue(requestObject.Proxy) as WebProxy; + // requestData is JSON in the request body for Signature 4 and 5 + if( bodyRequestData is DuoJsonRequestData jsonData && !string.IsNullOrEmpty(jsonData.RequestData) ) + { + requestMessage.Content = new StringContent(jsonData.RequestData, Encoding.UTF8, jsonData.ContentTypeHeader); + } + else if( bodyRequestData is DuoJsonRequestDataObject { RequestData: not null } jsonDataWithObject ) + { + var jsonFormattingSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; + + requestMessage.Content = new StringContent(JsonConvert.SerializeObject(jsonDataWithObject.RequestData, jsonFormattingSettings), Encoding.UTF8, jsonDataWithObject.ContentTypeHeader); + } + else + { + throw new DuoException($"{method} specified but either DuoJsonRequestData was not provided or DuoJsonRequestData.RequestData was null or empty (And signaturetype was >=4)"); + } } else { - actualProxy = requestObject.Proxy as WebProxy; + // requestData is FormUrl encoded in the request body for Signature 2 + if( bodyRequestData is DuoParamRequestData paramData ) + { + requestMessage.Content = new FormUrlEncodedContent(paramData.RequestData); + } + else + { + throw new DuoException($"{method} specified but either DuoParamRequestData was not provided or DuoParamRequestData.RequestData was null (And signaturetype was <4)"); + } } - hasProxyServer = (actualProxy.Address != null); - } - else - { - hasProxyServer = false; } - return hasProxyServer; - } - #endregion Private Methods - - #region Private DllImport - private enum WinHttp_Access_Type - { - WINHTTP_ACCESS_TYPE_DEFAULT_PROXY = 0, - WINHTTP_ACCESS_TYPE_NO_PROXY = 1, - WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3, - /// - /// Undocumented; supported on Win8.1+ per NET framework source. - /// - WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY = 4 - } - - [StructLayout(LayoutKind.Sequential)] - private class WINHTTP_PROXY_INFO - { - public int dwAccessType; - [MarshalAs(UnmanagedType.LPWStr)] - public string lpszProxy; - [MarshalAs(UnmanagedType.LPWStr)] - public string lpszProxyBypass; - } - [DllImport("winhttp.dll", CharSet = CharSet.Unicode)] - private static extern bool WinHttpGetDefaultProxyConfiguration([In, Out] WINHTTP_PROXY_INFO proxyInfo); - - [DllImport("winhttp.dll", CharSet = CharSet.Unicode)] - private static extern int WinHttpOpen([MarshalAs(UnmanagedType.LPWStr)] string pwszUserAgent, - WinHttp_Access_Type dwAccessType, - [MarshalAs(UnmanagedType.LPWStr)] string pwszProxyName, - [MarshalAs(UnmanagedType.LPWStr)] string pwszProxyBypass, - int dwFlags); - - [DllImport("winhttp.dll", CharSet = CharSet.Unicode)] - private static extern bool WinHttpCloseHandle(int hInternet); - #endregion Private DllImport - } - - [Serializable] - public class DuoException : Exception - { - public int HttpStatus { get; private set; } - - public DuoException(int http_status, string message, Exception inner) - : base(message, inner) - { - this.HttpStatus = http_status; - } - - protected DuoException(System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext ctxt) - : base(info, ctxt) - { } - } - - [Serializable] - public class ApiException : DuoException - { - public int Code { get; private set; } - public string ApiMessage { get; private set; } - public string ApiMessageDetail { get; private set; } - - public ApiException(int code, - int http_status, - string api_message, - string api_message_detail) - : base(http_status, FormatMessage(code, api_message, api_message_detail), null) - { - this.Code = code; - this.ApiMessage = api_message; - this.ApiMessageDetail = api_message_detail; - } - - protected ApiException(System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext ctxt) - : base(info, ctxt) - { } - - private static string FormatMessage(int code, - string api_message, - string api_message_detail) - { - return String.Format( - "Duo API Error {0}: '{1}' ('{2}')", code, api_message, api_message_detail); + // Make request and send response back to parent + return await WebRequest.SendAsync(requestMessage); } - } - - [Serializable] - public class BadResponseException : DuoException - { - public BadResponseException(int http_status, Exception inner) - : base(http_status, FormatMessage(http_status, inner), inner) - { } - - protected BadResponseException(System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext ctxt) - : base(info, ctxt) - { } - private static string FormatMessage(int http_status, Exception inner) + /// + /// Validate the server certificate + /// + private bool _ServerCertificateCustomValidationCallback(HttpRequestMessage arg1, X509Certificate2? arg2, X509Chain? arg3, SslPolicyErrors arg4) { - string inner_message = "(null)"; - if (inner != null) + #if DEBUG + // Check if certificate validation is enabled + if( !_TLSCertificateValidation ) { - inner_message = String.Format("'{0}'", inner.Message); + return true; } - return String.Format( - "Got error {0} with HTTP Status {1}", inner_message, http_status); - } - } - - public interface SleepService - { - void Sleep(int ms); - } - - public interface RandomService - { - int GetInt(int maxInt); - } - - class ThreadSleepService : SleepService - { - public void Sleep(int ms) - { - System.Threading.Thread.Sleep(ms); + #endif + + // Get the certificates + var CertificateValidation = ( CustomRootCertificates != null ) + ? CertificatePinnerFactory.GetCustomRootCertificatesPinner(CustomRootCertificates) + : CertificatePinnerFactory.GetDuoCertificatePinner(); + + // Perform cert validation + return CertificateValidation(arg1, arg2, arg3, arg4); } - } - - class SystemRandomService : RandomService - { - private Random rand = new Random(); - - public int GetInt(int maxInt) + + /// Helper to format a User-Agent string with some information about + /// the operating system / .NET runtime + /// e.g. "FooClient/1.0" + private string _FormatUserAgent(string product_name) { - return rand.Next(maxInt); + return $"{product_name} ({Environment.OSVersion}; .NET {Environment.Version})"; } + #endregion Private Methods } } diff --git a/duo_api_csharp/Endpoints/AdminAPIv2.cs b/duo_api_csharp/Endpoints/AdminAPIv2.cs new file mode 100644 index 0000000..ac11d0f --- /dev/null +++ b/duo_api_csharp/Endpoints/AdminAPIv2.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +// ReSharper disable PartialTypeWithSinglePart +// ReSharper disable NotAccessedField.Local +namespace duo_api_csharp.Endpoints +{ + /// + /// Version 2 of the Duo Admin API + /// + public sealed partial class AdminAPIv2 + { + private readonly DuoAPI duo_api; + internal AdminAPIv2(DuoAPI duo_api) + { + this.duo_api = duo_api; + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Endpoints/AdminGroups.cs b/duo_api_csharp/Endpoints/AdminGroups.cs new file mode 100644 index 0000000..d8769fe --- /dev/null +++ b/duo_api_csharp/Endpoints/AdminGroups.cs @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Models; +using duo_api_csharp.Classes; +using duo_api_csharp.Models.v1; + +namespace duo_api_csharp.Endpoints +{ + /// + /// Version 1 of the Duo Admin API + /// + public sealed partial class AdminAPIv1 + { + /// + /// Duo Admin API - Groups + /// https://duo.com/docs/adminapi#groups + /// + public AdminAPIv1_Groups Groups { get; } = new(duo_api); + } + + /// + /// Duo Admin API - Groups + /// https://duo.com/docs/adminapi#groups + /// + public sealed class AdminAPIv1_Groups + { + #region Internal constructor + private readonly DuoAPI duo_api; + internal AdminAPIv1_Groups(DuoAPI duo_api) + { + this.duo_api = duo_api; + } + #endregion Internal constructor + + #region Retrieve Groups + /// + /// Returns a paged list of groups. To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 100 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// + /// A list of group ids used to fetch multiple groups by group_ids. You can provide up to 100 group_ids. + /// + /// Group response model(s) + /// API Exception + public async Task>> GetGroups(int limit = 100, int offset = 0, string[]? group_id_list = null) + { + // Check paging bounds + if( limit is > 100 or < 1 ) limit = 100; + if( offset < 1 ) offset = 0; + + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary + { + { "offset", $"{offset}" }, + { "limit", $"{limit}" } + } + }; + + if( group_id_list != null ) + { + requestParam.RequestData.Add("group_id_list", JsonConvert.SerializeObject(group_id_list)); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + "/admin/v1/groups", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Retrieve Groups + + #region Manipulate Groups + /// + /// Create a new group + /// Requires "Grant write resource" API permission. + /// + /// + /// Group model + /// + /// Model of created group + /// API Exception + public async Task> CreateGroup(DuoGroupRequestModel group_model) + { + // Check model + group_model = (DuoGroupRequestModel)group_model.GetBaseClass(typeof(DuoGroupRequestModel)); + if( string.IsNullOrEmpty(group_model.Name) ) + { + throw new DuoException("Name is required in DuoGroupRequestModel for CreateGroup"); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(group_model); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + "/admin/v1/groups", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Create multiple groups + /// Requires "Grant write resource" API permission. + /// + /// + /// Array of group models + /// + /// Array of Models of created groups + /// API Exception + public async Task>> CreateGroup(IEnumerable group_model) + { + return await CreateGroup(group_model.ToArray()); + } + + /// + /// Create multiple groups + /// Requires "Grant write resource" API permission. + /// + /// + /// Array of group models + /// + /// Array of Models of created groups + /// API Exception + public async Task>> CreateGroup(DuoGroupRequestModel[] group_model) + { + // Check model + var processedModels = new List(); + foreach( var group in group_model ) + { + if( string.IsNullOrEmpty(group.Name) ) + { + throw new DuoException("Name is required in DuoGroupRequestModel for CreateGroup"); + } + + processedModels.Add((DuoGroupRequestModel)group.GetBaseClass(typeof(DuoGroupRequestModel))); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(new{ groups = JsonConvert.SerializeObject(processedModels) }); + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Post, + "/admin/v1/groups/bulk_create", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Change the groupname, groupname aliases, full name, status, and/or notes section of the group with ID group_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// Group model + /// + /// API Exception + public async Task ModifyGroup(DuoGroupRequestModel group_model) + { + // Check groupid + group_model = (DuoGroupRequestModel)group_model.GetBaseClass(typeof(DuoGroupRequestModel)); + var groupid = group_model.GroupID; + if( string.IsNullOrEmpty(groupid) ) + { + throw new DuoException("groupid is required in DuoGroupRequestModel for ModifyGroup"); + } + + // Make API request + group_model.GroupID = null; + var requestParam = new DuoJsonRequestDataObject(group_model); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + $"/admin/v1/groups/{groupid}", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Delete the group with ID group_id from the system. The API will not delete phones associated only with that group right away; remove them immediately with Delete Phone. + /// This method returns 200 if the phone was found or if no such phone exists. + /// Requires "Grant write resource" API permission. + /// + /// Groups deleted by the API do not get moved into the Trash view as "Pending Deletion" as they would if removed by directory sync, + /// group deletion, or interactively from the Duo Admin Panel, and therefore are not available for restoration. + /// Groups deleted via the API are immediately and permanently removed from Duo. + /// + /// + /// Group model + /// + /// API Exception + public async Task DeleteGroup(DuoGroupRequestModel group_model) + { + if( group_model.GroupID == null ) throw new DuoException("Invalid GroupID in request model"); + return await DeleteGroup(group_model.GroupID); + } + + /// + /// Delete the group with ID group_id from the system. The API will not delete phones associated only with that group right away; remove them immediately with Delete Phone. + /// This method returns 200 if the phone was found or if no such phone exists. + /// Requires "Grant write resource" API permission. + /// + /// Groups deleted by the API do not get moved into the Trash view as "Pending Deletion" as they would if removed by directory sync, + /// group deletion, or interactively from the Duo Admin Panel, and therefore are not available for restoration. + /// Groups deleted via the API are immediately and permanently removed from Duo. + /// + /// + /// Group ID of the group to delete + /// + /// API Exception + public async Task DeleteGroup(string group_id) + { + // Check groupid + if( string.IsNullOrEmpty(group_id) ) + { + throw new DuoException("groupid is required in DuoGroupRequestModel for ModifyGroup"); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Delete, + $"/admin/v1/groups/{group_id}" + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Manipulate Groups + } +} \ No newline at end of file diff --git a/duo_api_csharp/Endpoints/AdminUsers.cs b/duo_api_csharp/Endpoints/AdminUsers.cs new file mode 100644 index 0000000..4a172b8 --- /dev/null +++ b/duo_api_csharp/Endpoints/AdminUsers.cs @@ -0,0 +1,1585 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Models; +using duo_api_csharp.Classes; +using duo_api_csharp.Models.v1; + +namespace duo_api_csharp.Endpoints +{ + /// + /// Version 1 of the Duo Admin API + /// + public sealed partial class AdminAPIv1(DuoAPI duo_api) + { + /// + /// Duo Admin API - Users + /// https://duo.com/docs/adminapi#users + /// + public AdminAPIv1_Users Users { get; } = new(duo_api); + } + + /// + /// Duo Admin API - Users + /// https://duo.com/docs/adminapi#users + /// + public sealed class AdminAPIv1_Users + { + #region Internal constructor + private readonly DuoAPI duo_api; + internal AdminAPIv1_Users(DuoAPI duo_api) + { + this.duo_api = duo_api; + JsonConvert.DefaultSettings = () => new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; + } + #endregion Internal constructor + + #region Retrieve Users + /// + /// Returns a paged list of users. To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 300 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// + /// A list of user ids used to fetch multiple users by user_id. You can provide up to 100 user_id values. + /// If you provide this parameter, you must not provide the username, email or user_name_list parameters. The limit and offset parameters will be ignored. + /// + /// + /// A list of usernames used to fetch multiple users by username. You can provide up to 100 usernames. + /// If you provide this parameter, you must not provide the username, email or user_name_list parameters. The limit and offset parameters will be ignored. + /// + /// User response model(s) + /// API Exception + public async Task>> GetUsers(int limit = 100, int offset = 0, string[]? user_id_list = null, string[]? username_list = null) + { + // Check paging bounds + if( limit is > 300 or < 1 ) limit = 100; + if( offset < 1 ) offset = 0; + + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary + { + { "offset", $"{offset}" }, + { "limit", $"{limit}" } + } + }; + + if( user_id_list != null && username_list != null ) + { + throw new DuoException("user_id_list and username_list cannot both be populated in the same request"); + } + else if( user_id_list != null ) + { + requestParam.RequestData.Add("user_id_list", JsonConvert.SerializeObject(user_id_list)); + } + else if( username_list != null ) + { + requestParam.RequestData.Add("username_list", JsonConvert.SerializeObject(username_list)); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + "/admin/v1/users", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Return a single user based on a search of either the username or email address of the user. + /// Requires "Grant read resource" API permission. + /// + /// + /// Specify a user name (or username alias) to look up a single user. + /// + /// + /// Specify an email address to look up a single user. + /// + /// User response model + /// API Exception + public async Task> GetUser(string? username = null, string? email = null) + { + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary() + }; + + if( username != null && email != null ) + { + throw new DuoException("username and email cannot both be populated in the same request"); + } + else if( username != null ) + { + requestParam.RequestData.Add("username", username); + } + else if( email != null ) + { + requestParam.RequestData.Add("email", email); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + "/admin/v1/users", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return new DuoResponseModel + { + ErrorMessageDetail = apiResponse.ResponseData.ErrorMessageDetail, + Response = apiResponse.ResponseData.Response?.FirstOrDefault(), + ResponseMetadata = apiResponse.ResponseData.ResponseMetadata, + ErrorMessage = apiResponse.ResponseData.ErrorMessage, + ErrorCode = apiResponse.ResponseData.ErrorCode, + Status = apiResponse.ResponseData.Status + }; + } + + /// + /// Return a single user based on a search of either the username or email address of the user. + /// Requires "Grant read resource" API permission. + /// + /// + /// User ID + /// + /// User response model + /// API Exception + public async Task> GetUserById(string userid) + { + // Validate userid + if( string.IsNullOrEmpty(userid) ) + { + throw new DuoException("Invalid userid in request"); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Get, + $"/admin/v1/users/{userid}" + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Retrieve Users + + #region Manipulate Users + /// + /// Create a new user + /// Requires "Grant write resource" API permission. + /// + /// + /// User model + /// + /// Model of created user + /// API Exception + public async Task> CreateUser(DuoUserRequestModel user_model) + { + // Check model + user_model = (DuoUserRequestModel)user_model.GetBaseClass(typeof(DuoUserRequestModel)); + if( string.IsNullOrEmpty(user_model.Username) ) + { + throw new DuoException("username is required in DuoUserRequestModel for CreateUser"); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(user_model); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + "/admin/v1/users", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Create multiple users + /// Requires "Grant write resource" API permission. + /// + /// + /// Array of user models + /// + /// Array of Models of created users + /// API Exception + public async Task>> CreateUser(IEnumerable user_model) + { + return await CreateUser(user_model.ToArray()); + } + + /// + /// Create multiple users + /// Requires "Grant write resource" API permission. + /// + /// + /// Array of user models + /// + /// Array of Models of created users + /// API Exception + public async Task>> CreateUser(DuoUserRequestModel[] user_model) + { + // Check model + var processedModels = new List(); + foreach( var user in user_model ) + { + if( string.IsNullOrEmpty(user.Username) ) + { + throw new DuoException("username is required in DuoUserRequestModel for CreateUser"); + } + + processedModels.Add((DuoUserRequestModel)user.GetBaseClass(typeof(DuoUserRequestModel))); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(new{ users = JsonConvert.SerializeObject(processedModels) }); + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Post, + "/admin/v1/users/bulk_create", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Change the username, username aliases, full name, status, and/or notes section of the user with ID user_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model + /// + /// API Exception + public async Task ModifyUser(DuoUserRequestModel user_model) + { + // Check userid + user_model = (DuoUserRequestModel)user_model.GetBaseClass(typeof(DuoUserRequestModel)); + var userid = user_model.UserID; + if( string.IsNullOrEmpty(userid) ) + { + throw new DuoException("userid is required in DuoUserRequestModel for ModifyUser"); + } + + // Make API request + user_model.UserID = null; + var requestParam = new DuoJsonRequestDataObject(user_model); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + $"/admin/v1/users/{userid}", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Delete the user with ID user_id from the system. The API will not delete phones associated only with that user right away; remove them immediately with Delete Phone. + /// This method returns 200 if the phone was found or if no such phone exists. + /// Requires "Grant write resource" API permission. + /// + /// Users deleted by the API do not get moved into the Trash view as "Pending Deletion" as they would if removed by directory sync, + /// user deletion, or interactively from the Duo Admin Panel, and therefore are not available for restoration. + /// Users deleted via the API are immediately and permanently removed from Duo. + /// + /// + /// User model + /// + /// API Exception + public async Task DeleteUser(DuoUserRequestModel user_model) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await DeleteUser(user_model.UserID); + } + + /// + /// Delete the user with ID user_id from the system. The API will not delete phones associated only with that user right away; remove them immediately with Delete Phone. + /// This method returns 200 if the phone was found or if no such phone exists. + /// Requires "Grant write resource" API permission. + /// + /// Users deleted by the API do not get moved into the Trash view as "Pending Deletion" as they would if removed by directory sync, + /// user deletion, or interactively from the Duo Admin Panel, and therefore are not available for restoration. + /// Users deleted via the API are immediately and permanently removed from Duo. + /// + /// + /// User ID of the user to delete + /// + /// API Exception + public async Task DeleteUser(string user_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("userid is required in DuoUserRequestModel for ModifyUser"); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Delete, + $"/admin/v1/users/{user_id}" + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Enroll a user with user name username and email address email and send them an enrollment email that expires after valid_secs seconds. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model + /// + /// + /// The number of seconds the enrollment code should remain valid. Default: 2592000 (30 days). + /// + /// API Exception + public async Task> EnrollUser(DuoUserRequestModel user_model, int valid_secs = 2592000) + { + if( user_model.Username == null ) throw new DuoException("Invalid Username in request model"); + if( user_model.Email == null ) throw new DuoException("Invalid Email in request model"); + return await EnrollUser(user_model.Username, user_model.Email, valid_secs); + } + + /// + /// Enroll a user with user name username and email address email and send them an enrollment email that expires after valid_secs seconds. + /// Requires "Grant write resource" API permission. + /// + /// + /// The user name (or username alias) of the user to enroll. + /// + /// + /// The email address of this user. + /// + /// + /// The number of seconds the enrollment code should remain valid. Default: 2592000 (30 days). + /// + /// API Exception + public async Task> EnrollUser(string username, string email, int valid_secs = 2592000) + { + // Check username + if( string.IsNullOrEmpty(username) || string.IsNullOrEmpty(email) ) + { + throw new DuoException("username and email are required for EnrollUser"); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(new{ username, email, valid_secs = $"{valid_secs}" }); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + $"/admin/v1/users/enroll", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Manipulate Users + + #region Bypass Codes + /// + /// Clear all existing bypass codes for the user with ID user_id and return a list of count newly generated bypass codes, or specify codes that expire after valid_secs seconds, or reuse_count uses. + /// To preserve existing bypass codes instead of clearing them the request must specify preserve_existing=true. + /// + /// + /// User model to create bypass codes for + /// + /// + /// Number of new bypass codes to create. At most 10 codes (the default) can be created at a time. + /// Codes will be generated randomly. + /// + /// + /// Array of codes to use. Mutually exclusive with count. + /// + /// + /// Preserves existing bypass codes while creating new ones. Either true or false; effectively false if not specified. + /// If true and the request would result the target user reaching the limit of 100 codes per user, or if codes is used and specifies a bypass code that already exists for the target user, + /// then an error is returned and no bypass codes are created for nor cleared from the user. + /// + /// + /// The number of times generated bypass codes can be used. If 0, the codes will have an infinite reuse_count. + /// Default: 1. + /// + /// + /// The number of seconds for which generated bypass codes remain valid. + /// If 0 (the default) the codes will never expire. + /// + /// Array of bypass codes + /// API Exception + public async Task>> CreateUserBypassCodes(DuoUserRequestModel user_model, int count = 10, string[]? codes = null, bool preserve_existing = false, int reuse_count = 1, int valid_secs = 0) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await CreateUserBypassCodes(user_model.UserID, count, codes, preserve_existing, reuse_count, valid_secs); + } + + /// + /// Clear all existing bypass codes for the user with ID user_id and return a list of count newly generated bypass codes, or specify codes that expire after valid_secs seconds, or reuse_count uses. + /// To preserve existing bypass codes instead of clearing them the request must specify preserve_existing=true. + /// + /// + /// User ID to create bypass codes for + /// + /// + /// Number of new bypass codes to create. At most 10 codes (the default) can be created at a time. + /// Codes will be generated randomly. + /// + /// + /// Array of codes to use. Mutually exclusive with count. + /// + /// + /// Preserves existing bypass codes while creating new ones. Either true or false; effectively false if not specified. + /// If true and the request would result the target user reaching the limit of 100 codes per user, or if codes is used and specifies a bypass code that already exists for the target user, + /// then an error is returned and no bypass codes are created for nor cleared from the user. + /// + /// + /// The number of times generated bypass codes can be used. If 0, the codes will have an infinite reuse_count. + /// Default: 1. + /// + /// + /// The number of seconds for which generated bypass codes remain valid. + /// If 0 (the default) the codes will never expire. + /// + /// Array of bypass codes + /// API Exception + public async Task>> CreateUserBypassCodes(string user_id, int count = 10, string[]? codes = null, bool preserve_existing = false, int reuse_count = 1, int valid_secs = 0) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("userid is required for CreateUserBypassCodes"); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(new + { + preserve_existing = preserve_existing ? "true" : "false", + codes = codes != null ? string.Join(",", codes) : null, + reuse_count = $"{reuse_count}", + valid_secs = $"{valid_secs}", + count = $"{count}", + }); + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Post, + $"/admin/v1/users/{user_id}/bypass_codes", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Returns a paged list of bypass code metadata associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. D + /// Does not return the actual bypass codes. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model to retrieve bypass codes for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 300 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User bypass code model(s) + /// API Exception + public async Task>> GetUserBypassCodes(DuoUserRequestModel user_model, int limit = 100, int offset = 0) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await GetUserBypassCodes(user_model.UserID, limit, offset); + } + + /// + /// Returns a paged list of bypass code metadata associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. D + /// Does not return the actual bypass codes. + /// Requires "Grant read resource" API permission. + /// + /// + /// User ID to retrieve bypass codes for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 300 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User bypass code model(s) + /// API Exception + public async Task>> GetUserBypassCodes(string user_id, int limit = 100, int offset = 0) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("userid is required for CreateUserBypassCodes"); + } + + // Check paging bounds + if( limit is > 300 or < 1 ) limit = 100; + if( offset < 1 ) offset = 0; + + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary + { + { "offset", $"{offset}" }, + { "limit", $"{limit}" } + } + }; + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + $"/admin/v1/users/{user_id}/bypass_codes", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Bypass Codes + + #region User Groups + /// + /// Returns a paged list of groups associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User Model to retrieve groups for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserGroups(DuoUserRequestModel user_model, int limit = 100, int offset = 0) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await GetUserGroups(user_model.UserID, limit, offset); + } + + /// + /// Returns a paged list of groups associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User ID to retrieve groups for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserGroups(string user_id, int limit = 100, int offset = 0) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for GetUserGroups"); + } + + // Check paging bounds + if( limit is > 500 or < 1 ) limit = 100; + if( offset < 1 ) offset = 0; + + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary + { + { "offset", $"{offset}" }, + { "limit", $"{limit}" } + } + }; + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + $"/admin/v1/users/{user_id}/groups", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Associate a group with ID group_id with the user with ID user_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model to associate with the group + /// + /// + /// Group model to associate with the user + /// + /// + public async Task AssociateGroupWithUser(DuoUserRequestModel user_model, DuoGroupRequestModel group_model) + { + if( group_model.GroupID == null ) throw new DuoException("Invalid GroupID in request model"); + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await AssociateGroupWithUser(user_model.UserID, group_model.GroupID); + } + + /// + /// Associate a group with ID group_id with the user with ID user_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// User id to associate with the group + /// + /// + /// Group id to associate with the user + /// + /// + public async Task AssociateGroupWithUser(string user_id, string group_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for AssociateGroupWithUser"); + } + if( string.IsNullOrEmpty(group_id) ) + { + throw new DuoException("group_id is required for AssociateGroupWithUser"); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(new + { + group_id + }); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + $"/admin/v1/users/{user_id}/groups", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Disassociate a group from the user with ID user_id. + /// This method will return 200 if the group was found or if no such group exists. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model to disassociate from the group + /// + /// + /// Group model to disassociate from the user + /// + /// + public async Task DisassociateGroupFromUser(DuoUserRequestModel user_model, DuoGroupRequestModel group_model) + { + if( group_model.GroupID == null ) throw new DuoException("Invalid GroupID in request model"); + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await DisassociateGroupFromUser(user_model.UserID, group_model.GroupID); + } + + /// + /// Disassociate a group from the user with ID user_id. + /// This method will return 200 if the group was found or if no such group exists. + /// Requires "Grant write resource" API permission. + /// + /// + /// User id to disassociate from the group + /// + /// + /// Group id to disassociate from the user + /// + /// + public async Task DisassociateGroupFromUser(string user_id, string group_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for AssociateGroupWithUser"); + } + if( string.IsNullOrEmpty(group_id) ) + { + throw new DuoException("group_id is required for AssociateGroupWithUser"); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Delete, + $"/admin/v1/users/{user_id}/groups/{group_id}" + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion User Groups + + #region Phones + /// + /// Returns a paged list of phones associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model to retrieve phones for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserPhones(DuoUserRequestModel user_model, int limit = 100, int offset = 0) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await GetUserPhones(user_model.UserID, limit, offset); + } + + /// + /// Returns a paged list of phones associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User ID to retrieve phones for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserPhones(string user_id, int limit = 100, int offset = 0) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for GetUserPhones"); + } + + // Check paging bounds + if( limit is > 500 or < 1 ) limit = 100; + if( offset < 1 ) offset = 0; + + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary + { + { "offset", $"{offset}" }, + { "limit", $"{limit}" } + } + }; + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + $"/admin/v1/users/{user_id}/phones", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Associate a phone with the user with ID user_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model to associate with phone + /// + /// + /// Phone model to associate with user + /// + public async Task AssociatePhoneWithUser(DuoUserRequestModel user_model, DuoPhoneRequestModel phone_model) + { + if( phone_model.PhoneID == null ) throw new DuoException("Invalid PhoneID in request model"); + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await AssociatePhoneWithUser(user_model.UserID, phone_model.PhoneID); + } + + /// + /// Associate a phone with the user with ID user_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// User id to associate with phone + /// + /// + /// Phone id to associate with user + /// + public async Task AssociatePhoneWithUser(string user_id, string phone_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for AssociatePhoneWithUser"); + } + if( string.IsNullOrEmpty(phone_id) ) + { + throw new DuoException("phone_id is required for AssociatePhoneWithUser"); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(new + { + phone_id + }); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + $"/admin/v1/users/{user_id}/phones", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Disassociate a phone from the user with ID user_id. + /// The API will not automatically delete the phone after removing the last user association; remove it permanently with Delete Phone. + /// This method returns 200 if the phone was found or if no such phone exists. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model to disassociate from phone + /// + /// + /// Phone model to disassociate from user + /// + public async Task DisassociatePhoneFromUser(DuoUserRequestModel user_model, DuoPhoneRequestModel phone_model) + { + if( phone_model.PhoneID == null ) throw new DuoException("Invalid PhoneID in request model"); + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await DisassociatePhoneFromUser(user_model.UserID, phone_model.PhoneID); + } + + /// + /// Disassociate a phone from the user with ID user_id. + /// The API will not automatically delete the phone after removing the last user association; remove it permanently with Delete Phone. + /// This method returns 200 if the phone was found or if no such phone exists. + /// Requires "Grant write resource" API permission. + /// + /// + /// User id to disassociate from phone + /// + /// + /// Phone id to disassociate from user + /// + public async Task DisassociatePhoneFromUser(string user_id, string phone_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for AssociatePhoneWithUser"); + } + if( string.IsNullOrEmpty(phone_id) ) + { + throw new DuoException("phone_id is required for AssociatePhoneWithUser"); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Delete, + $"/admin/v1/users/{user_id}/phones/{phone_id}" + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Phones + + #region Hardware Tokens + /// + /// Returns a paged list of OTP hardware tokens associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model to retrieve tokens for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserHardwareTokens(DuoUserRequestModel user_model, int limit = 100, int offset = 0) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await GetUserHardwareTokens(user_model.UserID, limit, offset); + } + + /// + /// Returns a paged list of OTP hardware tokens associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User ID to retrieve tokens for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserHardwareTokens(string user_id, int limit = 100, int offset = 0) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for GetUserPhones"); + } + + // Check paging bounds + if( limit is > 500 or < 1 ) limit = 100; + if( offset < 1 ) offset = 0; + + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary + { + { "offset", $"{offset}" }, + { "limit", $"{limit}" } + } + }; + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + $"/admin/v1/users/{user_id}/tokens", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Associate a hardware token with the user with ID user_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model to associate with token + /// + /// + /// Token model to associate with user + /// + public async Task AssociateHardwareTokenWithUser(DuoUserRequestModel user_model, DuoHardwareTokenRequestModel token_model) + { + if( token_model.TokenID == null ) throw new DuoException("Invalid TokenID in request model"); + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await AssociateHardwareTokenWithUser(user_model.UserID, token_model.TokenID); + } + + /// + /// Associate a hardware token with the user with ID user_id. + /// Requires "Grant write resource" API permission. + /// + /// + /// User id to associate with token + /// + /// + /// Token id to associate with user + /// + public async Task AssociateHardwareTokenWithUser(string user_id, string token_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for AssociateHardwareTokenWithUser"); + } + if( string.IsNullOrEmpty(token_id) ) + { + throw new DuoException("token_id is required for AssociateHardwareTokenWithUser"); + } + + // Make API request + var requestParam = new DuoJsonRequestDataObject(new + { + token_id + }); + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + $"/admin/v1/users/{user_id}/tokens", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Disassociate a hardware token from the user with ID user_id. + /// This method will return 200 if the hardware token was found or if no such hardware token exists. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model to disassociate from token + /// + /// + /// Token model to disassociate from user + /// + public async Task DisassociateHardwareTokenWithUser(DuoUserRequestModel user_model, DuoHardwareTokenRequestModel token_model) + { + if( token_model.TokenID == null ) throw new DuoException("Invalid TokenID in request model"); + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await DisassociatePhoneFromUser(user_model.UserID, token_model.TokenID); + } + + /// + /// Disassociate a hardware token from the user with ID user_id. + /// This method will return 200 if the hardware token was found or if no such hardware token exists. + /// Requires "Grant write resource" API permission. + /// + /// + /// User id to disassociate from token + /// + /// + /// Token id to disassociate from user + /// + public async Task DisassociateHardwareTokenWithUser(string user_id, string token_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for DisassociateHardwareTokenWithUser"); + } + if( string.IsNullOrEmpty(token_id) ) + { + throw new DuoException("phone_id is required for DisassociateHardwareTokenWithUser"); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Delete, + $"/admin/v1/users/{user_id}/tokens/{token_id}" + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Hardware Tokens + + #region Other Tokens + /// + /// Returns a list of WebAuthn credentials associated with the user with ID user_id. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model to retrieve tokens for + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserWebAuthNTokens(DuoUserRequestModel user_model) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await GetUserWebAuthNTokens(user_model.UserID); + } + + /// + /// Returns a list of WebAuthn credentials associated with the user with ID user_id. + /// Requires "Grant read resource" API permission. + /// + /// + /// User id to retrieve tokens for + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserWebAuthNTokens(string user_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for GetUserPhones"); + } + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + $"/admin/v1/users/{user_id}/webauthncredentials" + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Returns a paged list of desktop authenticators associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model to retrieve authenticators for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserDesktopAuthenticators(DuoUserRequestModel user_model, int limit = 100, int offset = 0) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await GetUserDesktopAuthenticators(user_model.UserID, limit, offset); + } + + /// + /// Returns a paged list of desktop authenticators associated with the user with ID user_id. + /// To fetch all results, call repeatedly with the offset parameter as long as the result metadata has a next_offset value. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model to retrieve authenticators for + /// + /// + /// The maximum number of records returned. + /// Default: 100; Max: 500 + /// + /// + /// The offset from 0 at which to start record retrieval. + /// When used with "limit", the handler will return "limit" records starting at the n-th record, where n is the offset. + /// Default: 0 + /// + /// User group model(s) + /// API Exception + public async Task>> GetUserDesktopAuthenticators(string user_id, int limit = 100, int offset = 0) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for GetUserPhones"); + } + + // Check paging bounds + if( limit is > 500 or < 1 ) limit = 100; + if( offset < 1 ) offset = 0; + + // Request parameters + var requestParam = new DuoParamRequestData + { + RequestData = new Dictionary + { + { "offset", $"{offset}" }, + { "limit", $"{limit}" } + } + }; + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Get, + $"/admin/v1/users/{user_id}/desktopauthenticators", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Other Tokens + + #region Other Methods + /// + /// Initiate a sync to create, update, or mark for deletion the user specified by username against the directory specified by the directory_key. + /// The directory_key for a directory can be found by navigating to Users → Directory Sync in the Duo Admin Panel, + /// and then clicking on the configured directory. Learn more about syncing individual users from Active Directory, OpenLDAP, or Entra ID. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model of the user to sync + /// + /// + /// Key retrieved from the Web UI of the directory to sync + /// + /// API Exception + public async Task SyncUserFromDirectory(DuoUserRequestModel user_model, string directory_key) + { + if( user_model.Username == null ) throw new DuoException("Invalid Username in request model"); + return await SyncUserFromDirectory(user_model.Username, directory_key); + } + + /// + /// Initiate a sync to create, update, or mark for deletion the user specified by username against the directory specified by the directory_key. + /// The directory_key for a directory can be found by navigating to Users → Directory Sync in the Duo Admin Panel, + /// and then clicking on the configured directory. Learn more about syncing individual users from Active Directory, OpenLDAP, or Entra ID. + /// Requires "Grant write resource" API permission. + /// + /// + /// Username of the user to sync + /// + /// + /// Key retrieved from the Web UI of the directory to sync + /// + /// API Exception + public async Task SyncUserFromDirectory(string username, string directory_key) + { + // Check userid + if( string.IsNullOrEmpty(username) ) + { + throw new DuoException("username is required for SyncUserFromDirectory"); + } + if( string.IsNullOrEmpty(directory_key) ) + { + throw new DuoException("directory_key is required for SyncUserFromDirectory"); + } + + // Request parameters + var requestParam = new DuoJsonRequestDataObject(new + { + username + }); + + // Make API request + var apiResponse = await duo_api.APICallAsync>( + HttpMethod.Post, + $"/admin/v1/users/directorysync/{directory_key}/syncuser", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Sends a verification Duo Push to the user with ID user_id. + /// Verification pushes can also be sent from the Duo Admin Panel. + /// Requires "Grant write resource" API permission. + /// + /// + /// User model of the user to send the notification to + /// + /// + /// Phone model of the device to send the notification to + /// + /// Push ID which can be used to validate the message was accepted using VerifyPushResponse + /// API Exception + public async Task> SendVerificationPush(DuoUserRequestModel user_model, DuoPhoneRequestModel phone_model) + { + if( phone_model.PhoneID == null ) throw new DuoException("Invalid PhoneID in request model"); + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await SendVerificationPush(user_model.UserID, phone_model.PhoneID); + } + + /// + /// Sends a verification Duo Push to the user with ID user_id. + /// Verification pushes can also be sent from the Duo Admin Panel. + /// Requires "Grant write resource" API permission. + /// + /// + /// User id of the user to send the notification to + /// + /// + /// Phone id of the device to send the notification to + /// + /// Push ID which can be used to validate the message was accepted using VerifyPushResponse + /// API Exception + public async Task> SendVerificationPush(string user_id, string phone_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for SendVerificationPush"); + } + if( string.IsNullOrEmpty(phone_id) ) + { + throw new DuoException("phone_id is required for SendVerificationPush"); + } + + // Request parameters + var requestParam = new DuoJsonRequestDataObject(new + { + phone_id + }); + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Post, + $"/admin/v1/users/{user_id}/send_verification_push", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + + /// + /// Retrieves the verification push result for the user with ID user_id. Push response information will be available for 120 seconds after the push was sent, + /// after which this endpoint will return a 404. If no success or failure response was returned by this endpoint during these 120 seconds, + /// it can be assumed that the push has timed out. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model of the user to send the notification to + /// + /// + /// The ID of the Duo Push sent. + /// + /// Push response status + /// API Exception + public async Task> VerifyPushResponse(DuoUserRequestModel user_model, string push_id) + { + if( user_model.UserID == null ) throw new DuoException("Invalid UserID in request model"); + return await VerifyPushResponse(user_model.UserID, push_id); + } + + /// + /// Retrieves the verification push result for the user with ID user_id. Push response information will be available for 120 seconds after the push was sent, + /// after which this endpoint will return a 404. If no success or failure response was returned by this endpoint during these 120 seconds, + /// it can be assumed that the push has timed out. + /// Requires "Grant read resource" API permission. + /// + /// + /// User model of the user to send the notification to + /// + /// + /// The ID of the Duo Push sent. + /// + /// Push response status + /// API Exception + public async Task> VerifyPushResponse(string user_id, string push_id) + { + // Check userid + if( string.IsNullOrEmpty(user_id) ) + { + throw new DuoException("user_id is required for VerifyPushResponse"); + } + if( string.IsNullOrEmpty(push_id) ) + { + throw new DuoException("push_id is required for VerifyPushResponse"); + } + + // Request parameters + var requestParam = new DuoJsonRequestDataObject(new + { + push_id + }); + + // Make API request + var apiResponse = await duo_api.APICallAsync( + HttpMethod.Get, + $"/admin/v1/users/{user_id}/verification_push_response", + requestParam + ); + + // Return data + if( apiResponse.ResponseData == null ) + { + // All requests should always deserialise into a response + throw new DuoException("No response data from server", null, apiResponse.StatusCode, apiResponse.RequestSuccess); + } + + return apiResponse.ResponseData; + } + #endregion Other Methods + } +} \ No newline at end of file diff --git a/duo_api_csharp/Extensions/DateTimeExtensions.cs b/duo_api_csharp/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..f042567 --- /dev/null +++ b/duo_api_csharp/Extensions/DateTimeExtensions.cs @@ -0,0 +1,42 @@ +using System.Globalization; + +namespace duo_api_csharp.Extensions +{ + internal static class DateTimeExtensions + { + /// + /// Custom RFC822 implementation for Duo + /// + /// DateTime object + /// Date formatted string + internal static string DateToRFC822(this DateTime date) + { + // Can't use the "zzzz" format because it adds a ":" + // between the offset's hours and minutes. + var date_string = date.ToString("ddd, dd MMM yyyy HH:mm:ss", CultureInfo.InvariantCulture); + var offset = 0; + + // set offset if input date is not UTC time. + if( date.Kind != DateTimeKind.Utc ) + { + offset = TimeZoneInfo.Local.GetUtcOffset(date).Hours; + } + + string zone; + // + or -, then 0-pad, then offset, then more 0-padding. + if( offset <= 0 ) + { + offset *= -1; + zone = "-"; + } + else + { + zone = "+"; + } + + zone += offset.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0'); + date_string += " " + zone.PadRight(5, '0'); + return date_string; + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Extensions/JsonElementExtensions.cs b/duo_api_csharp/Extensions/JsonElementExtensions.cs deleted file mode 100644 index 7a8439e..0000000 --- a/duo_api_csharp/Extensions/JsonElementExtensions.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Text.Json; - -namespace Duo.Extensions -{ - internal static class JsonElementExtensions - { - /// - /// Converts a value contained within a System.Text.Json.JsonElement - /// into an object of the contained type - /// - /// - /// - /// - internal static object ConvertToObject(this JsonElement element) - { - switch (element.ValueKind) - { - case JsonValueKind.Undefined: - case JsonValueKind.Null: - return null; - case JsonValueKind.String: - return element.GetString(); - case JsonValueKind.Number: - if (element.TryGetInt32(out int intValue)) - { - return intValue; - } - if (element.TryGetInt64(out long longValue)) - { - return longValue; - } - if (element.TryGetDecimal(out decimal decimalValue)) - { - return decimalValue; - } - return element.GetDouble(); - case JsonValueKind.True: - case JsonValueKind.False: - return element.GetBoolean(); - case JsonValueKind.Object: - var sourceDict = JsonSerializer.Deserialize>(element.GetRawText()); - var targetDict = new Dictionary(); - foreach (var kvp in sourceDict) - { - targetDict.Add(kvp.Key, kvp.Value.ConvertToObject()); - } - return targetDict; - case JsonValueKind.Array: - // ArrayList was returned by the older serializer, so make sure to keep the type - var list = new ArrayList(); - foreach (var item in element.EnumerateArray()) - { - list.Add(item.ConvertToObject()); - } - return list; - default: - throw new InvalidOperationException("Unexpected JSON value kind: " + element.ValueKind); - } - } - } -} diff --git a/duo_api_csharp/Models/DuoAPIResponse.cs b/duo_api_csharp/Models/DuoAPIResponse.cs new file mode 100644 index 0000000..29a3625 --- /dev/null +++ b/duo_api_csharp/Models/DuoAPIResponse.cs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using System.Net; +using Newtonsoft.Json; + +namespace duo_api_csharp.Models +{ + /// + /// API Response Wrapper + /// + public class DuoAPIResponse + { + /// + /// The status code of the response + /// + public required HttpStatusCode StatusCode { get; init; } + + /// + /// If your request was successful or not + /// + public required bool RequestSuccess { get; init; } + + /// + /// The response data from the server + /// + public DuoResponseModel? ResponseData { get; set; } + + /// + /// Raw response data. Only set when no model is passed + /// + public string? RawResponseData { get; set; } + } + + /// + /// API Response Wrapper + /// + public class DuoAPIResponse + { + /// + /// The status code of the response + /// + public required HttpStatusCode StatusCode { get; init; } + + /// + /// If your request was successful or not + /// + public required bool RequestSuccess { get; init; } + + /// + /// The response data from the server + /// + public DuoResponseModel? ResponseData { get; set; } + } + + /// + /// Duo API Response Model + /// https://duo.com/docs/adminapi#response-format + /// + public class DuoResponseModel + { + /// + /// Response from the server on the request + /// Known values are OK and FAIL + /// + [JsonProperty("stat")] + public required string Status { get; set; } + + /// + /// Metadata for the response from the server + /// + [JsonProperty("metadata")] + public DuoResponseMetadataModel? ResponseMetadata { get; set; } + + /// + /// In the case of an error, this will indcate a server side error code + /// First three digits indicate the HTTP response code, the second two indicate a more specific error + /// EG, 40002 = 400, Bad Request; 02 = Invalid request parameters + /// + [JsonProperty("code")] + public int? ErrorCode { get; set; } + + /// + /// In the case of an error, this will contain the description for the error code provided + /// + [JsonProperty("message")] + public string? ErrorMessage { get; set; } + + /// + /// In the case of an error, this will contain additional information, if available + /// + [JsonProperty("message_detail")] + public string? ErrorMessageDetail { get; set; } + } + + /// + /// Duo API Response Model + /// https://duo.com/docs/adminapi#response-format + /// + public class DuoResponseModel : DuoResponseModel + { + /// + /// The response data from the request + /// + [JsonProperty("response")] + public T? Response { get; set; } + } + + /// + /// Paging metadata returned by the API + /// https://duo.com/docs/adminapi#response-paging + /// + public class DuoResponseMetadataModel + { + /// + /// An integer indicating the total number of objects retrieved by the API request across all pages of results. + /// + public int? TotalObjects { get; set; } + + /// + /// An integer indicating The offset from 0 at which to start the next paged set of results. + /// If not present in the metadata response, then there are no more pages of results left. + /// + public int? NextOffset { get; set; } + + /// + /// An integer indicating the offset from 0 at which the previous paged set of results started. + /// If you did not specify next_offset in the request, this defaults to 0 (the beginning of the results). + /// + public int? PrevOffset { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/DuoRequestData.cs b/duo_api_csharp/Models/DuoRequestData.cs new file mode 100644 index 0000000..bea259b --- /dev/null +++ b/duo_api_csharp/Models/DuoRequestData.cs @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +namespace duo_api_csharp.Models +{ + /// + /// Duo Request Data interface + /// + public interface DuoRequestData + { + /// + /// Content-Type header to send in the request + /// + public string ContentTypeHeader { get; } + } + + /// + /// Duo Request data - Form Encoded Parameters + /// + public class DuoParamRequestData : DuoRequestData + { + /// + /// Content-Type header to send in the request + /// + public string ContentTypeHeader => "application/x-www-form-urlencoded"; + + /// + /// Dictionary of parameters to send in the request + /// + public Dictionary RequestData { get; init; } = new(); + } + + /// + /// Duo Request data - JSON Data + /// + public class DuoJsonRequestData : DuoRequestData + { + /// + /// Content-Type header to send in the request + /// + public string ContentTypeHeader => "application/json"; + + /// + /// JSON data serialised as a string + /// + public string RequestData { get; set; } = ""; + } + + /// + /// Duo Request data - JSON Data + /// + /// JSON object to serialise + public class DuoJsonRequestDataObject(object jsonObj) : DuoRequestData + { + /// + /// Content-Type header to send in the request + /// + public string ContentTypeHeader => "application/json"; + + /// + /// Object to serialise as JSON + /// + public object? RequestData { get; init; } = jsonObj; + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoAdminModel.cs b/duo_api_csharp/Models/v1/DuoAdminModel.cs new file mode 100644 index 0000000..b24cd3f --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoAdminModel.cs @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using duo_api_csharp.Classes; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo User request data + /// This is not yet implemented + /// + public class DuoAdminRequestModel : DuoModelBase; + + /// + /// This is not yet implemented + /// + public class DuoAdminResponseModel : DuoUserRequestModel; +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoBypassCodeModel.cs b/duo_api_csharp/Models/v1/DuoBypassCodeModel.cs new file mode 100644 index 0000000..40dd045 --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoBypassCodeModel.cs @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; +using System.ComponentModel.DataAnnotations.Schema; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo Bypass Code request data + /// This represents read-write API response data that can also be used in an update request to the API + /// + public class DuoBypassCodeRequestModel : DuoModelBase + { + /// + /// The email address of the Duo administrator who created the bypass code. + /// + [JsonProperty("admin_email")] + public string? AdminEmail { get; set; } + + /// + /// The bypass code's identifier. Use with GET bypass code by ID. + /// + [JsonProperty("bypass_code_id")] + public string? BypassCodeID { get; set; } + + [JsonProperty("created")] + private long? _CreatedOn { get; set; } + + /// + /// The bypass code creation date + /// + [NotMapped] + public DateTime? CreatedOn + { + get + { + return Epoch.FromUnix(_CreatedOn); + } + set + { + _CreatedOn = Epoch.ToUnix(value); + } + } + + [JsonProperty("expiration")] + private long? _ExpiresOn { get; set; } + + /// + /// An integer indicating the expiration timestamp of the bypass code, + /// or null if the bypass code does not expire on a certain date. + /// + [NotMapped] + public DateTime? ExpiresOn + { + get + { + return Epoch.FromUnix(_ExpiresOn); + } + set + { + _ExpiresOn = Epoch.ToUnix(value); + } + } + + /// + /// An integer indicating the number of times the bypass code may be used before expiring, + /// or null if the bypass code has no limit on the number of times it may be used. + /// + [JsonProperty("reuse_count")] + public int? ReuseCount { get; set; } + } + + /// + /// Duo Bypass Code response data + /// This represents read-only API response data that cannot be updated via the API + /// This class inherits DuoBypassCodeRequestModel + /// + public class DuoBypassCodeResponseModel : DuoBypassCodeRequestModel + { + /// + /// Selected information about the end user attached to this bypass code. + /// + [JsonProperty("user")] + public DuoUserResponseModel? User { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoDesktopAuthenticatorModel.cs b/duo_api_csharp/Models/v1/DuoDesktopAuthenticatorModel.cs new file mode 100644 index 0000000..94784ee --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoDesktopAuthenticatorModel.cs @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo Desktop Authenticator request data + /// This represents read-only API response data that cannot be updated via the API + /// + public class DuoDesktopAuthenticatorResponseModel : DuoModelBase + { + /// + /// The authenticator's ID. + /// + [JsonProperty("daid")] + public string? ID { get; set; } + + /// + /// The authenticator's Duo-specific identifier. + /// + [JsonProperty("dakey")] + public string? Key { get; set; } + + /// + /// The endpoint's hostname. + /// + [JsonProperty("device_name")] + public string? DeviceName { get; set; } + + /// + /// The version of Duo Desktop installed on the endpoint. + /// + [JsonProperty("duo_desktop_version")] + public string? DuoDesktopVersion { get; set; } + + /// + /// The endpoint's operating system platform. + /// + [JsonProperty("os_family")] + public string? OSFamily { get; set; } + + /// + /// Selected information about the end user attached to this Desktop Authenticator. + /// + [JsonProperty("user")] + public DuoUserResponseModel? User { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoGroupModel.cs b/duo_api_csharp/Models/v1/DuoGroupModel.cs new file mode 100644 index 0000000..866ee10 --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoGroupModel.cs @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo Group request data + /// This represents read-write API response data that can also be used in an update request to the API + /// + public class DuoGroupRequestModel : DuoModelBase + { + /// + /// Group ID + /// + [JsonProperty("group_id")] + public string? GroupID { get; set; } + + /// + /// The group's name. + /// If managed by directory sync, then the name returned here also indicates the source directory. + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// The group's description. + /// + [JsonProperty("desc")] + public string? Description { get; set; } + + /// + /// The group's authentication status. May be one of: + /// "Active" The users in the group must complete secondary authentication. + /// "Bypass" The users in the group will bypass secondary authentication after completing primary authentication. + /// "Disabled" The users in the group will not be able to authenticate. + /// + [JsonProperty("status")] + public string? Status { get; set; } + } + + /// + /// Duo Group response data + /// This represents read-only API response data that cannot be updated via the API + /// This class inherits DuoGroupRequestModel + /// + public class DuoGroupResponseModel : DuoGroupRequestModel; +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoHardwareTokenModel.cs b/duo_api_csharp/Models/v1/DuoHardwareTokenModel.cs new file mode 100644 index 0000000..ded4dcc --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoHardwareTokenModel.cs @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo Hardware Token request data + /// This represents read-write API response data that can also be used in an update request to the API + /// + public class DuoHardwareTokenRequestModel : DuoModelBase + { + /// + /// The hardware token's unique ID. + /// + [JsonProperty("token_id")] + public string? TokenID { get; set; } + + /// + /// The serial number of the hardware token; used to uniquely identify the hardware token when paired with type. + /// + [JsonProperty("serial")] + public string? Serial { get; set; } + + /// + /// The type of hardware token. One of: + /// "h6" HOTP-6 hardware token + /// "h8" HOTP-8 hardware token + /// "yk" YubiKey AES hardware token + /// "d1" Duo-D100 tokens (NB: are imported when purchased from Duo and may not be created via the Admin API) + /// + [JsonProperty("type")] + public string? Type { get; set; } + + /// + /// The HOTP secret. This parameter is required for HOTP-6 and HOTP-8 hardware tokens. + /// + [JsonProperty("secret")] + public string? Secret { get; set; } + + /// + /// Initial value for the HOTP counter. This parameter is only valid for HOTP-6 and HOTP-8 hardware tokens. Default: 0. + /// + [JsonProperty("counter")] + public string? Counter { get; set; } + + /// + /// The 12-character hexadecimal YubiKey private ID. This parameter is required for YubiKey hardware tokens. + /// + [JsonProperty("private_id")] + public string? PrivateID { get; set; } + + /// + /// The 32-character hexadecimal YubiKey AES key. This parameter is required for YubiKey hardware tokens. + /// + [JsonProperty("aes_key")] + public string? AESKey { get; set; } + + } + + /// + /// Duo Hardware Token response data + /// This represents read-only API response data that cannot be updated via the API + /// This class inherits DuoHardwareTokenRequestModel + /// + public class DuoHardwareTokenResponseModel : DuoHardwareTokenRequestModel + { + /// + /// A list of end users associated with this hardware token. + /// + [JsonProperty("users")] + public IEnumerable? Users { get; set; } + + /// + /// A list of administrators associated with this hardware token. + /// + [JsonProperty("admins")] + public IEnumerable? Admins { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoPhoneModel.cs b/duo_api_csharp/Models/v1/DuoPhoneModel.cs new file mode 100644 index 0000000..b3a9b21 --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoPhoneModel.cs @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; +using System.ComponentModel.DataAnnotations.Schema; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo Phone request data + /// This represents read-write API response data that can also be used in an update request to the API + /// + public class DuoPhoneRequestModel : DuoModelBase + { + /// + /// Phone Unique ID + /// + [JsonProperty("phone_id")] + public string? PhoneID { get; set; } + + /// + /// The phone number; E.164 format recommended (i.e. "+17345551212"). + /// If no leading plus sign is provided then it is assumed to be a United States number and an implicit "+1" country code is prepended. Dashes and spaces are ignored. + /// A phone with a smartphone platform but no number is a tablet. + /// + [JsonProperty("number")] + public string? Number { get; set; } + + /// + /// Free-form label for the phone. + /// + [JsonProperty("name")] + public string? Name { get; set; } + + /// + /// The extension. + /// + [JsonProperty("extension")] + public string? Extension { get; set; } + + /// + /// The phone type. + /// One of: "unknown", "mobile", or "landline". + /// + [JsonProperty("type")] + public string? Type { get; set; } + + /// + /// The phone platform. + /// One of: "unknown", "google android", "apple ios", "windows phone 7", "rim blackberry", "java j2me", "palm webos", "symbian os", "windows mobile", or "generic smartphone". + /// "windows phone" is accepted as a synonym for "windows phone 7". This includes devices running Windows Phone 8. + /// If a smartphone's exact platform is unknown but it will have Duo Mobile installed, use "generic smartphone" and generate an activation code. + /// When the phone is activated its platform will be automatically detected. + /// + [JsonProperty("platform")] + public string? Platform { get; set; } + + /// + /// The time (in seconds) to wait after the number picks up and before dialing the extension. + /// + [JsonProperty("predelay")] + public string? PreDelay { get; set; } + + /// + /// The time (in seconds) to wait after the extension is dialed and before the speaking the prompt. + /// + [JsonProperty("postdelay")] + public string? PostDelay { get; set; } + } + + /// + /// Duo Phone response data + /// This represents read-only API response data that cannot be updated via the API + /// This class inherits DuoPhoneRequestModel + /// + public class DuoPhoneResponseModel : DuoPhoneRequestModel + { + /// + /// Has this phone been activated for Duo Mobile yet? Either true or false. + /// + [JsonProperty("activated")] + [JsonConverter(typeof(DuoBoolConverter))] + public bool? Activated { get; set; } + + /// + /// List of strings, each a factor that can be used with the device. + /// "auto" The device is valid for automatic factor selection (e.g. phone or push). + /// "push" The device is activated for Duo Push. + /// "phone" The device can receive phone calls. + /// "sms" The device can receive batches of SMS passcodes. + /// "mobile_otp" The device can generate passcodes with Duo Mobile. + /// + [JsonProperty("capabilities")] + public IEnumerable? Capabilities { get; set; } + + /// + /// The encryption status of an Android or iOS device file system. + /// One of: "Encrypted", "Unencrypted", or "Unknown". Blank for other platforms. + /// This information is available to Duo Premier and Duo Advantage plan customers. + /// + [JsonProperty("encrypted")] + public string? Encrypted { get; set; } + + /// + /// Whether an Android or iOS phone is configured for biometric verification. + /// One of: "Configured", "Disabled", or "Unknown". Blank for other platforms. + /// This information is available to Duo Premier and Duo Advantage plan customers. + /// + [JsonProperty("fingerprint")] + public string? Fingerprint { get; set; } + + /// + /// Whether screen lock is enabled on an Android or iOS phone. + /// One of: "Locked", "Unlocked", or "Unknown". Blank for other platforms. + /// This information is available to Duo Premier and Duo Advantage plan customers. + /// + [JsonProperty("screenlock")] + public string? Screenlock { get; set; } + + /// + /// Whether an iOS or Android device is jailbroken or rooted. + /// One of: "Not Tampered", "Tampered", or "Unknown". Blank for other platforms. + /// This information is available to Duo Premier and Duo Advantage plan customers. + /// + [JsonProperty("tampered")] + public string? Tampered { get; set; } + + /// + /// The phone's model. + /// + [JsonProperty("model")] + public string? Model { get; set; } + + /// + /// Have SMS passcodes been sent to this phone? Either true or false. + /// + [JsonProperty("sms_passcodes_sent")] + [JsonConverter(typeof(DuoBoolConverter))] + public bool? SMSPasscodesSent { get; set; } + + /// + /// Time of the last contact between Duo's service and the activated Duo Mobile app installed on the phone. + /// Null if the device has never activated Duo Mobile or if the platform does not support it. + /// + [JsonProperty("last_seen")] + public DateTime? LastSeenOn { get; set; } + + /// + /// A list of end users associated with this hardware token. + /// + [JsonProperty("users")] + public IEnumerable? Users { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoUserModel.cs b/duo_api_csharp/Models/v1/DuoUserModel.cs new file mode 100644 index 0000000..b6c1f6d --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoUserModel.cs @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; +using System.ComponentModel.DataAnnotations.Schema; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo User request data + /// This represents read-write API response data that can also be used in an update request to the API + /// + public class DuoUserRequestModel : DuoModelBase + { + /// + /// Unique ID of the user + /// + [JsonProperty("user_id")] + public string? UserID { get; set; } + + /// + /// Username + /// + [JsonProperty("username")] + public string? Username { get; set; } + + /// + /// The user's real name (or full name). + /// + [JsonProperty("realname")] + public string? RealName { get; set; } + + /// + /// Email Address + /// + [JsonProperty("email")] + public string? Email { get; set; } + + /// + /// If true, the user is automatically prompted to use their last-used authentication method when authenticating. + /// If false, the user is shown a list of authentication methods to initiate authentication. + /// Only effective in the Universal Prompt. + /// + [JsonProperty("enable_auto_prompt")] + [JsonConverter(typeof(DuoBoolConverter))] + public bool? EnableAutoPrompt { get; set; } + + /// + /// The user's status. One of: + /// "active" The user must complete secondary authentication. + /// "bypass" The user will bypass secondary authentication after completing primary authentication. + /// "disabled" The user will not be able to log in. + /// "locked out" The user has been locked out due to a specific reason stored in the “lockout_reason” field. + /// "pending deletion" The user was marked for deletion by a Duo admin from the Admin Panel, by the system for inactivity, or by directory sync. If not restored within seven days the user is permanently deleted. + /// Note that when a user is a member of a group, the group status may override the individual user's status. Group status is not shown in the user response. + /// + [JsonProperty("status")] + public string? Status { get; set; } + + /// + /// Notes about this user. Viewable in the Duo Admin Panel. + /// + [JsonProperty("notes")] + public string? Notes { get; set; } + } + + /// + /// Duo User response data + /// This represents read-only API response data that cannot be updated via the API + /// This class inherits DuoUserRequestModel + /// + public class DuoUserResponseModel : DuoUserRequestModel + { + /// + /// Map of the user's username alias(es). Up to eight aliases may exist. + /// + [JsonProperty("aliases")] + public Dictionary? Aliases { get; set; } + + [JsonProperty("created")] + private long? _CreatedOn { get; set; } + + /// + /// The user's creation date + /// + [NotMapped] + public DateTime? CreatedOn + { + get + { + return Epoch.FromUnix(_CreatedOn); + } + set + { + _CreatedOn = Epoch.ToUnix(value); + } + } + + /// + /// The user's unique identifier imported by a directory sync. + /// This is the id if the user is synced from Entra ID or object_guid if the user is synced from Active Directory. + /// Not returned for users managed by OpenLDAP sync or users not managed by a directory sync. + /// + [JsonProperty("external_id")] + public string? ExternalID { get; set; } + + /// + /// List of groups to which this user belongs. + /// + [JsonProperty("groups")] + public IEnumerable? Groups { get; set; } + + /// + /// Is true if the user has a phone, hardware token, U2F token, WebAuthn security key, or other WebAuthn method available for authentication. + /// Otherwise, false. + /// + [JsonProperty("is_enrolled")] + public bool? IsEnrolled { get; set; } + + [JsonProperty("last_directory_sync")] + private long? _LastDirectorySync { get; set; } + + /// + /// An integer indicating the last update to the user via directory sync as a Unix timestamp, + /// or null if the user has never synced with an external directory or if the directory that originally created the user has been deleted from Duo. + /// + [NotMapped] + public DateTime? LastDirectorySync + { + get + { + return Epoch.FromUnix(_LastDirectorySync); + } + set + { + _LastDirectorySync = Epoch.ToUnix(value); + } + } + + [JsonProperty("last_login")] + private long? _LastLogin { get; set; } + + /// + /// An integer indicating the last time this user logged in, as a Unix timestamp, + /// or null if the user has not logged in. + /// + [NotMapped] + public DateTime? LastLogin + { + get + { + return Epoch.FromUnix(_LastLogin); + } + set + { + _LastLogin = Epoch.ToUnix(value); + } + } + + /// + /// The user's lockout_reason. One of: + /// "Failed Attempts" The user was locked out due to excessive authentication attempts. + /// "Not enrolled" The user was locked out due to being not enrolled for a given period of time after the user was created. + /// "Admin disabled" The user was locked out by an admin from Duo Trust Monitor. + /// "Admin API disabled" The user's status was set to "locked out" by Admin API. + /// + [JsonProperty("lockout_reason")] + public string? LockoutReason { get; set; } + + /// + /// A list of phones that this user can use. + /// + [JsonProperty("phones")] + public IEnumerable? Phones { get; set; } + + /// + /// A list of hardware tokens that this user can use. + /// + [JsonProperty("tokens")] + public IEnumerable? HardwareTokens { get; set; } + + /// + /// A list of WebAuthn authenticators that this user can use. + /// + [JsonProperty("webauthncredentials")] + public IEnumerable? WebAuthNCredentials { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoVerificationPushModel.cs b/duo_api_csharp/Models/v1/DuoVerificationPushModel.cs new file mode 100644 index 0000000..23d9cb8 --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoVerificationPushModel.cs @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo Verification Push response data + /// This represents read-only API response data that cannot be updated via the API + /// + public class DuoVerificationPushResponseModel : DuoModelBase + { + /// + /// The ID of the Duo Push sent. + /// + [JsonProperty("push_id")] + public string? PushID { get; set; } + + /// + /// The Duo Push sent to the user contains this confirmation code. + /// + [JsonProperty("confirmation_code")] + public string? ConfirmationCode { get; set; } + } + + /// + /// Duo Verification Push Validation response data + /// This represents read-only API response data that cannot be updated via the API + /// + public class DuoVerificationValidationResponseModel : DuoModelBase + { + /// + /// The ID of the Duo Push sent. + /// + [JsonProperty("push_id")] + public string? PushID { get; set; } + + /// + /// The result of the verification push sent. One of: + /// approve: User approved the push. + /// deny: User denied the push. + /// fraud: User marked the push as fraud. + /// waiting: User has not responded to the push yet. + /// + [JsonProperty("result")] + public string? Result { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/Models/v1/DuoWebAuthNModel.cs b/duo_api_csharp/Models/v1/DuoWebAuthNModel.cs new file mode 100644 index 0000000..6f67757 --- /dev/null +++ b/duo_api_csharp/Models/v1/DuoWebAuthNModel.cs @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using Newtonsoft.Json; +using duo_api_csharp.Classes; +using System.ComponentModel.DataAnnotations.Schema; + +namespace duo_api_csharp.Models.v1 +{ + /// + /// Duo WebAuthN response data + /// This represents read-only API response data that cannot be updated via the API + /// + public class DuoWebAuthNResponseModel : DuoModelBase + { + /// + /// Free-form label for this WebAuthn credential. + /// + [JsonProperty("credential_name")] + public string? CredentialName { get; set; } + + /// + /// A derived nickname for this WebAuthn credential. This value cannot be changed by a user or admin. + /// Present when attached to a user. Example: Windows Hello or iCloud Keychain. + /// + [JsonProperty("label")] + public string? Label { get; set; } + + /// + /// The credential's Duo-specific identifier. + /// + [JsonProperty("webauthnkey")] + public string? WebAuthNKey { get; set; } + + [JsonProperty("date_added")] + private long? _DateAdded { get; set; } + + /// + /// The Unix timestamp of when this WebAuthn credential was registered in Duo. + /// + [NotMapped] + public DateTime? DateAdded + { + get + { + return Epoch.FromUnix(_DateAdded); + } + set + { + _DateAdded = Epoch.ToUnix(value); + } + } + + [JsonProperty("date_last_used")] + private long? _DateLastUsed { get; set; } + + /// + /// The Unix timestamp of when this WebAuthn credential was last used to authenticate with Duo. + /// If null, this credential has not been used yet. + /// + [NotMapped] + public DateTime? DateLastUsed + { + get + { + return Epoch.FromUnix(_DateLastUsed); + } + set + { + _DateLastUsed = Epoch.ToUnix(value); + } + } + + /// + /// If true, this credential can be used from multiple devices. + /// If false, this credential can only be used on one device. + /// + [JsonProperty("backup_eligible")] + [JsonConverter(typeof(DuoBoolConverter))] + public bool? BackupEligible { get; set; } + + /// + /// If true, this credential has been backed up and can be used from multiple devices. + /// If false, this credential has not been backed up. + /// + [JsonProperty("backup_status")] + [JsonConverter(typeof(DuoBoolConverter))] + public bool? BackupStatus { get; set; } + + /// + /// If true, this credential can be used for both MFA and Passwordless authentication. + /// If false, this credential can only be used for MFA authentication. + /// + [JsonProperty("passwordless_authorized")] + [JsonConverter(typeof(DuoBoolConverter))] + public bool? PasswordlessAuthorised { get; set; } + + /// + /// If true, the authenticator is capable of locally verifying the user’s identity. + /// If false, the authenticator cannot perform user verification. + /// + [JsonProperty("uv_capable")] + [JsonConverter(typeof(DuoBoolConverter))] + public bool? UVCapable { get; set; } + + /// + /// A unique identifier that conveys the authenticator's make and model, or the passkey's provider identity. + /// This value cannot be verified as accurate by Duo. + /// + [JsonProperty("aaguid")] + public string? GUID { get; set; } + + /// + /// An identifier randomly generated by the authenticator for this WebAuthn credential. + /// + [JsonProperty("credential_id")] + public string? CredentialID { get; set; } + + /// + /// The registration flow that was used to register this WebAuthn credential. + /// One of platform, cross-platform, or unknown. + /// + [JsonProperty("registered_as")] + public string? RegisteredAs { get; set; } + + /// + /// An array of values the browser will use to try and communicate with an authenticator during a Duo authentication attempt. + /// + [JsonProperty("transports")] + public IEnumerable? Transports { get; set; } + + /// + /// Selected information about the end user attached to this WebAuthn credential. + /// + [JsonProperty("user")] + public DuoUserResponseModel? User { get; set; } + + /// + /// Selected information about the administrator attached to this WebAuthn credential. + /// + [JsonProperty("admin")] + public DuoAdminResponseModel? Admin { get; set; } + } +} \ No newline at end of file diff --git a/duo_api_csharp/ca_certs.pem b/duo_api_csharp/Resources/ca_certs.pem similarity index 100% rename from duo_api_csharp/ca_certs.pem rename to duo_api_csharp/Resources/ca_certs.pem diff --git a/duo_api_csharp/SignatureTypes/DuoSignatureV2.cs b/duo_api_csharp/SignatureTypes/DuoSignatureV2.cs new file mode 100644 index 0000000..d66740f --- /dev/null +++ b/duo_api_csharp/SignatureTypes/DuoSignatureV2.cs @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using System.Text; +using duo_api_csharp.Models; +using duo_api_csharp.Extensions; +using System.Security.Cryptography; + +namespace duo_api_csharp.SignatureTypes +{ + internal class DuoSignatureV2(string ikey, string skey, string host, DateTime requesttime) : IDuoSignatureTypes + { + public DuoSignatureTypes SignatureType => DuoSignatureTypes.Duo_SignatureTypeV2; + public Dictionary DefaultRequestHeaders => new() + { + { "X-Duo-Date", requesttime.DateToRFC822() } + }; + + public string SignRequest(HttpMethod method, string path, DateTime requestDate, DuoRequestData? requestData, Dictionary? _) + { + // Return HMAC signature for request + var signature = _GenerateSignature(method, path, requestDate, requestData); + var auth = $"{ikey}:{_HmacSign($"{signature}")}"; + return _Encode64(auth); + } + + internal string _GenerateSignature(HttpMethod method, string path, DateTime requestDate, DuoRequestData? requestData) + { + var signingHeader = $"{requestDate.DateToRFC822()}\n{method.Method.ToUpper()}\n{host}\n{path}"; + var signingParams = _CanonParams(requestData); + return $"{signingHeader}\n{signingParams}"; + } + + internal string _CanonParams(DuoRequestData? requestData) + { + var signingParams = new StringBuilder(); + if( requestData is DuoParamRequestData data ) + { + foreach( var (paramKey, paramValue) in data.RequestData.OrderBy(q => Uri.EscapeDataString(q.Key)) ) + { + if( signingParams.Length != 0 ) signingParams.Append('&'); + signingParams.Append($"{Uri.EscapeDataString(paramKey)}={Uri.EscapeDataString(paramValue)}"); + } + } + + return signingParams.ToString(); + } + + internal string? _HmacSign(string data) + { + var key_bytes = Encoding.UTF8.GetBytes(skey); + var hmac = new HMACSHA512(key_bytes); + + var data_bytes = Encoding.UTF8.GetBytes(data); + hmac.ComputeHash(data_bytes); + if( hmac.Hash == null ) return null; + + var hex = BitConverter.ToString(hmac.Hash); + return hex.Replace("-", "").ToLower(); + } + + internal string _Encode64(string plaintext) + { + var plaintext_bytes = Encoding.UTF8.GetBytes(plaintext); + return Convert.ToBase64String(plaintext_bytes); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/SignatureTypes/DuoSignatureV4.cs b/duo_api_csharp/SignatureTypes/DuoSignatureV4.cs new file mode 100644 index 0000000..d23a732 --- /dev/null +++ b/duo_api_csharp/SignatureTypes/DuoSignatureV4.cs @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using System.Text; +using Newtonsoft.Json; +using duo_api_csharp.Models; +using duo_api_csharp.Extensions; +using System.Security.Cryptography; + +namespace duo_api_csharp.SignatureTypes +{ + internal class DuoSignatureV4(string ikey, string skey, string host, DateTime requesttime) : IDuoSignatureTypes + { + public DuoSignatureTypes SignatureType => DuoSignatureTypes.Duo_SignatureTypeV4; + public Dictionary DefaultRequestHeaders => new() + { + { "X-Duo-Date", requesttime.DateToRFC822() } + }; + + public string SignRequest(HttpMethod method, string path, DateTime requestDate, DuoRequestData? requestData, Dictionary? requestHeaders) + { + // Return HMAC signature for request + var signature = _GenerateSignature(method, path, requestDate, requestData); + var auth = $"{ikey}:{_HmacSign($"{signature}")}"; + return _Encode64(auth); + } + + internal string _GenerateSignature(HttpMethod method, string path, DateTime requestDate, DuoRequestData? requestData) + { + var signingHeader = $"{requestDate.DateToRFC822()}\n{method.Method.ToUpper()}\n{host}\n{path}"; + var bodyData = ""; + + // Check request data for signing + if( requestData is DuoJsonRequestData jsonData ) + { + bodyData = jsonData.RequestData; + } + else if( requestData is DuoJsonRequestDataObject { RequestData: not null } jsonDataWithObject ) + { + var jsonFormattingSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; + + bodyData = JsonConvert.SerializeObject(jsonDataWithObject.RequestData, jsonFormattingSettings); + } + + return $"{signingHeader}\n\n{_Sha512Hash(bodyData)}"; + } + + internal string _CanonParams(DuoRequestData? requestData) + { + var signingParams = new StringBuilder(); + if( requestData is DuoParamRequestData data ) + { + foreach( var (paramKey, paramValue) in data.RequestData.OrderBy(q => Uri.EscapeDataString(q.Key)) ) + { + if( signingParams.Length != 0 ) signingParams.Append('&'); + signingParams.Append($"{Uri.EscapeDataString(paramKey)}={Uri.EscapeDataString(paramValue)}"); + } + } + + return signingParams.ToString(); + } + + private string? _HmacSign(string data) + { + var key_bytes = Encoding.UTF8.GetBytes(skey); + var hmac = new HMACSHA512(key_bytes); + + var data_bytes = Encoding.UTF8.GetBytes(data); + hmac.ComputeHash(data_bytes); + if( hmac.Hash == null ) return null; + + var hex = BitConverter.ToString(hmac.Hash); + return hex.Replace("-", "").ToLower(); + } + + private string _Sha512Hash(string data) + { + var data_bytes = Encoding.UTF8.GetBytes(data); + var hash_data = SHA512.HashData(data_bytes); + var hex = BitConverter.ToString(hash_data); + return hex.Replace("-", "").ToLower(); + } + + private string _Encode64(string plaintext) + { + var plaintext_bytes = Encoding.UTF8.GetBytes(plaintext); + return Convert.ToBase64String(plaintext_bytes); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/SignatureTypes/DuoSignatureV5.cs b/duo_api_csharp/SignatureTypes/DuoSignatureV5.cs new file mode 100644 index 0000000..4349cc9 --- /dev/null +++ b/duo_api_csharp/SignatureTypes/DuoSignatureV5.cs @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using System.Text; +using Newtonsoft.Json; +using duo_api_csharp.Models; +using duo_api_csharp.Extensions; +using System.Security.Cryptography; + +namespace duo_api_csharp.SignatureTypes +{ + internal class DuoSignatureV5(string ikey, string skey, string host, DateTime requesttime) : IDuoSignatureTypes + { + private readonly List _AddedHeaders = []; + + public DuoSignatureTypes SignatureType => DuoSignatureTypes.Duo_SignatureTypeV5; + public Dictionary DefaultRequestHeaders => new() + { + { "X-Duo-Date", requesttime.DateToRFC822() } + }; + + public string SignRequest(HttpMethod method, string path, DateTime requestDate, DuoRequestData? requestData, Dictionary? requestHeaders) + { + // Return HMAC signature for request + var signature = _GenerateSignature(method, path, requestDate, requestData, requestHeaders); + var auth = $"{ikey}:{_HmacSign($"{signature}")}"; + return _Encode64(auth); + } + + internal string _GenerateSignature(HttpMethod method, string path, DateTime requestDate, DuoRequestData? requestData, Dictionary? requestHeaders) + { + var signingHeader = $"{requestDate.DateToRFC822()}\n{method.Method.ToUpper()}\n{host}\n{path}"; + var signingParams = ""; + var bodyData = ""; + + // Check request data for signing + if( requestData is DuoParamRequestData paramData ) + { + signingParams = _CanonParams(paramData); + } + else if( requestData is DuoJsonRequestData jsonData ) + { + bodyData = jsonData.RequestData; + } + else if( requestData is DuoJsonRequestDataObject { RequestData: not null } jsonDataWithObject ) + { + var jsonFormattingSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; + + bodyData = JsonConvert.SerializeObject(jsonDataWithObject.RequestData, jsonFormattingSettings); + } + + // Format hash of X-Duo headers + var signingHeaders = new StringBuilder(); + if( requestHeaders != null ) + { + lock( _AddedHeaders) + { + foreach( var (paramKey, paramValue) in requestHeaders ) + { + if( !_ValidateHeader(paramKey, paramValue) ) continue; + _AddedHeaders.Add(paramKey.ToLower()); + + if( signingHeaders.Length != 0 ) signingHeaders.Append('\x00'); + signingHeaders.Append(paramKey.ToLower()); + signingHeaders.Append('\x00'); + signingHeaders.Append(paramValue); + } + + _AddedHeaders.Clear(); + } + } + + return $"{signingHeader}\n{signingParams}\n{_Sha512Hash(bodyData)}\n{_Sha512Hash(signingHeaders.ToString())}"; + } + + internal string _CanonParams(DuoRequestData? requestData) + { + var signingParams = new StringBuilder(); + if( requestData is DuoParamRequestData data ) + { + foreach( var (paramKey, paramValue) in data.RequestData.OrderBy(q => Uri.EscapeDataString(q.Key)) ) + { + if( signingParams.Length != 0 ) signingParams.Append('&'); + signingParams.Append($"{Uri.EscapeDataString(paramKey)}={Uri.EscapeDataString(paramValue)}"); + } + } + + return signingParams.ToString(); + } + + private string? _HmacSign(string data) + { + var key_bytes = Encoding.UTF8.GetBytes(skey); + var hmac = new HMACSHA512(key_bytes); + + var data_bytes = Encoding.UTF8.GetBytes(data); + hmac.ComputeHash(data_bytes); + if( hmac.Hash == null ) return null; + + var hex = BitConverter.ToString(hmac.Hash); + return hex.Replace("-", "").ToLower(); + } + + private string _Sha512Hash(string data) + { + var data_bytes = Encoding.UTF8.GetBytes(data); + var hash_data = SHA512.HashData(data_bytes); + var hex = BitConverter.ToString(hash_data); + return hex.Replace("-", "").ToLower(); + } + + private string _Encode64(string plaintext) + { + var plaintext_bytes = Encoding.UTF8.GetBytes(plaintext); + return Convert.ToBase64String(plaintext_bytes); + } + + private bool _ValidateHeader(string headername, string value) + { + if( string.IsNullOrEmpty(headername) || string.IsNullOrEmpty(value) ) return false; + if( headername.Contains('\x00') || value.Contains('\x00') ) return false; + if( !headername.ToLower().Contains("x-duo-") ) return false; + return !_AddedHeaders.Contains(headername.ToLower()); + } + } +} \ No newline at end of file diff --git a/duo_api_csharp/SignatureTypes/IDuoSignatureTypes.cs b/duo_api_csharp/SignatureTypes/IDuoSignatureTypes.cs new file mode 100644 index 0000000..6ef0b73 --- /dev/null +++ b/duo_api_csharp/SignatureTypes/IDuoSignatureTypes.cs @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates + * All rights reserved + * https://github.com/duosecurity/duo_api_csharp + */ + +using duo_api_csharp.Models; + +namespace duo_api_csharp.SignatureTypes +{ + /// + /// The authentication signature type to use + /// The default and current scheme is v5 + /// + public enum DuoSignatureTypes + { + /// + /// Original auth scheme + /// + Duo_SignatureTypeV2 = 2, + /// + /// Scheme used for JSON body requests only + /// + Duo_SignatureTypeV4 = 4, + /// + /// Current default authentication scheme + /// + Duo_SignatureTypeV5 = 5 + } + + internal interface IDuoSignatureTypes + { + /// + /// The version of signature this class implements + /// + public DuoSignatureTypes SignatureType { get; } + + /// + /// Additional request headers added by this signature type that must be sent + /// + public Dictionary DefaultRequestHeaders { get; } + + /// + /// Sign the request + /// + /// HTTP Method + /// Path to endpoint + /// Request date for X-Duo-Date header + /// Data being sent to the server + /// Request headers + /// Signed bearer token + public string SignRequest(HttpMethod method, string path, DateTime requestDate, DuoRequestData? requestData, Dictionary? requestHeaders); + } +} \ No newline at end of file diff --git a/duo_api_csharp/duo_api_csharp.csproj b/duo_api_csharp/duo_api_csharp.csproj index 1f30135..e726aa3 100644 --- a/duo_api_csharp/duo_api_csharp.csproj +++ b/duo_api_csharp/duo_api_csharp.csproj @@ -1,98 +1,38 @@ - + + - Debug - AnyCPU - {6E96C9D9-0825-4D26-83C7-8A62180F8FB9} - Library - false - ClassLibrary - v4.8 - 512 - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - false - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - false + net8.0 + enable + enable + latestmajor + + true duo_api_csharp + - - ..\packages\Microsoft.Bcl.AsyncInterfaces.6.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll - - - - - ..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll - - - - - - - ..\packages\System.Memory.4.5.4\lib\net461\System.Memory.dll - - - - ..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll - - - ..\packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll - - - ..\packages\System.Text.Encodings.Web.6.0.0\lib\net461\System.Text.Encodings.Web.dll - - - ..\packages\System.Text.Json.6.0.2\lib\net461\System.Text.Json.dll - - - ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll - - - ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll - - - - - + + + + false + None + + - - - - + + - + + - + + <_Parameter1>$(AssemblyName).Tests + - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - + + \ No newline at end of file diff --git a/duo_api_csharp/packages.config b/duo_api_csharp/packages.config deleted file mode 100644 index 4b44fc2..0000000 --- a/duo_api_csharp/packages.config +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/examples/App.config b/examples/App.config deleted file mode 100644 index 193aecc..0000000 --- a/examples/App.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/examples/Examples.csproj b/examples/Examples.csproj deleted file mode 100644 index 35bc283..0000000 --- a/examples/Examples.csproj +++ /dev/null @@ -1,59 +0,0 @@ - - - - - Debug - AnyCPU - {C089A10B-646D-407E-A2B8-848C6C522B13} - Exe - Examples - Examples - v4.8 - 512 - true - true - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - - - - - {6e96c9d9-0825-4d26-83c7-8a62180f8fb9} - duo_api_csharp - - - - \ No newline at end of file diff --git a/examples/Program.cs b/examples/Program.cs deleted file mode 100644 index 110606b..0000000 --- a/examples/Program.cs +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates - * All rights reserved - */ - -using System; -using System.Collections.Generic; - -public class Program -{ - static int Main(string[] args) - { - if (args.Length < 3) - { - System.Console.WriteLine("Usage: "); - return 1; - } - string ikey = args[0]; - string skey = args[1]; - string host = args[2]; - var client = new Duo.DuoApi(ikey, skey, host); - var parameters = new Dictionary(); - var r = client.JSONApiCall>( - "GET", "/admin/v1/info/authentication_attempts", parameters); - var attempts = r["authentication_attempts"] as Dictionary; - foreach (KeyValuePair info in attempts) - { - var s = String.Format("{0} authentication(s) ended with {1}.", - info.Value, - info.Key); - System.Console.WriteLine(s); - } - - // /admin/v1/users returns a JSON Array instead of an object. - var users = client.JSONApiCall( - "GET", "/admin/v1/users", parameters); - System.Console.WriteLine(String.Format("{0} users.", users.Count)); - foreach (Dictionary user in users) - { - System.Console.WriteLine( - "\t" + "Username: " + (user["username"] as string)); - } - - // paging call - int? offset = 0; - while (offset != null) - { - var jsonResponse = client.JSONPagingApiCall("GET", "/admin/v1/users", parameters, (int)offset, 10); - var pagedUsers = jsonResponse["response"] as System.Collections.ArrayList; - System.Console.WriteLine(String.Format("{0} users at offset {1}", pagedUsers.Count, offset)); - foreach (Dictionary user in pagedUsers) - { - System.Console.WriteLine( - "\t" + "Username: " + (user["username"] as string)); - } - var metadata = jsonResponse["metadata"] as Dictionary; - if (metadata.ContainsKey("next_offset")) - { - offset = metadata["next_offset"] as int?; - } - else - { - offset = null; - } - } - - return 0; - } -} - diff --git a/examples/Properties/AssemblyInfo.cs b/examples/Properties/AssemblyInfo.cs deleted file mode 100644 index 6a556c3..0000000 --- a/examples/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Examples")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Examples")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("c089a10b-646d-407e-a2b8-848c6c522b13")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/test/ApiCallTest.cs b/test/ApiCallTest.cs deleted file mode 100644 index e2d27fc..0000000 --- a/test/ApiCallTest.cs +++ /dev/null @@ -1,548 +0,0 @@ -using Duo; -using System; -using System.Net; -using System.Threading; -using System.Collections.Generic; -using System.IO; -using Xunit; - -// Subclass DuoApi so we can test using HTTP rather than HTTPS -public class TestDuoApi : DuoApi -{ - public MockSleeper sleeper; - public MockRandom random; - public TestDuoApi(string ikey, string skey, string host) - : this(ikey, skey, host, null) - { - } - public TestDuoApi(string ikey, string skey, string host, string user_agent) - : this(ikey, skey, host, user_agent, new MockSleeper(), new MockRandom()) - { - } - - public TestDuoApi(string ikey, string skey, string host, string user_agent, MockSleeper sleeper, MockRandom random) - : base(ikey, skey, host, user_agent, "http", sleeper, random) - { - this.sleeper = sleeper; - this.random = random; - } -} - -public class MockSleeper : SleepService -{ - public List sleepCalls = new List(); - - public void Sleep(int ms) - { - sleepCalls.Add(ms); - } -} - -public class MockRandom : RandomService -{ - public List randomCalls = new List(); - - public int GetInt(int maxInt) - { - randomCalls.Add(maxInt); - return 123; - } -} - -public class TestServer -{ - public int requestsToHandle = 1; - - public TestServer(string ikey, string skey) - { - this.ikey = ikey; - this.skey = skey; - } - - public delegate string TestDispatchHandler(HttpListenerContext ctx); - public TestDispatchHandler handler - { - - get - { - lock (this) - { - return this._handler; - } - } - set - { - lock (this) - { - this._handler = value; - } - } - } - - public HttpListener listener; - - public void Run() - { - this.listener = new HttpListener(); - this.listener.Prefixes.Add("http://localhost:8080/"); - this.listener.Start(); - - for (int i = 0; i < requestsToHandle; i++) - { - // Wait for a request - HttpListenerContext context = listener.GetContext(); - HttpListenerRequest request = context.Request; - - // process the request - string path = request.Url.AbsolutePath; - string responseString; - - try - { - responseString = handler(context); - } - catch (Exception e) - { - responseString = e.ToString(); - } - - // write the response - HttpListenerResponse response = context.Response; - System.IO.Stream output = response.OutputStream; - - if (!String.IsNullOrEmpty(responseString)) - { - byte[] buffer = System.Text.Encoding.UTF8.GetBytes(responseString); - response.ContentLength64 = buffer.Length; - output.Write(buffer, 0, buffer.Length); - } - output.Close(); - } - - // shut down the listener - this.listener.Stop(); - } - - private string ikey; - private string skey; - private TestDispatchHandler _handler; -} -public class TestApiCall -{ - private const string test_ikey = "DI9FD6NAKXN4B9DTCCB7"; - private const string test_skey = "RScfSuMrpL52TaciEhGtZkGjg8W4JSe5luPL63J8"; - private const string test_host = "localhost:8080"; - - private TestServer srv; - private Thread srvThread; - private TestDuoApi api; - - /// - /// - /// - public TestApiCall() - { - api = new TestDuoApi(test_ikey, test_skey, test_host); - srv = new TestServer(test_ikey, test_skey); - srvThread = new Thread(srv.Run); - srvThread.Start(); - } - - ~TestApiCall() - { - srvThread.Join(); - } - - [Fact] - public void Test401Response() - { - srv.handler = delegate (HttpListenerContext ctx) - { - ctx.Response.StatusCode = 401; - return "Hello, Unauthorized World!"; - }; - - HttpStatusCode code; - string response = api.ApiCall("GET", "/401", new Dictionary(), 10000, out code); - Assert.Equal(HttpStatusCode.Unauthorized, code); - Assert.Equal("Hello, Unauthorized World!", response); - } - - [Fact] - public void Test200Response() - { - srv.handler = delegate (HttpListenerContext ctx) - { - return "Hello, World!"; - }; - - HttpStatusCode code; - string response = api.ApiCall("GET", "/hello", new Dictionary(), 10000, out code); - Assert.Equal(HttpStatusCode.OK, code); - Assert.Equal("Hello, World!", response); - } - - [Fact] - public void TestDefaultUserAgent() - { - srv.handler = delegate (HttpListenerContext ctx) - { - Console.WriteLine(String.Format("User-Agent is: {0}", ctx.Request.UserAgent)); - return ctx.Request.UserAgent; - }; - - HttpStatusCode code; - string response = api.ApiCall("GET", "/DefaultUserAgent", new Dictionary(), 10000, out code); - Assert.Equal(HttpStatusCode.OK, code); - Assert.StartsWith(api.DEFAULT_AGENT, response); - } - - [Fact] - public void TestCustomUserAgent() - { - api = new TestDuoApi(test_ikey, test_skey, test_host, "CustomUserAgent/1.0"); - srv.handler = delegate (HttpListenerContext ctx) - { - return ctx.Request.UserAgent; - }; - - HttpStatusCode code; - string response = api.ApiCall("GET", "/CustomUserAgent", new Dictionary(), 10000, out code); - Assert.Equal(HttpStatusCode.OK, code); - Assert.Equal("CustomUserAgent/1.0", response); - } - - [Fact] - public void TestGetParameterSigning() - { - Dictionary parameters = new Dictionary - { - {"param1", "foo"}, - {"param2", "bar"} - }; - - srv.handler = delegate (HttpListenerContext ctx) - { - if (ctx.Request.HttpMethod != "GET") - { - return "bad method!"; - } - if (ctx.Request.Url.AbsolutePath != "/get_params") - { - return "bad path!"; - } - - string expected_query_str = DuoApi.CanonicalizeParams(parameters); - if (("?" + expected_query_str) != ctx.Request.Url.Query) - { - return "bad query string!"; - } - - // Sign() is itself tested elsewhere - string date = ctx.Request.Headers["X-Duo-Date"]; - string expected_signature = api.Sign( - "GET", "/get_params", expected_query_str, date); - string authorization = ctx.Request.Headers["Authorization"]; - if (authorization != expected_signature) - { - return "bad signature"; - } - return "OK"; - - }; - - HttpStatusCode code; - string response = api.ApiCall("GET", "/get_params", parameters, 10000, out code); - Assert.Equal("OK", response); - } - - [Fact] - public void TestPostParameterSigning() - { - Dictionary parameters = new Dictionary - { - {"param1", "foo"}, - {"param2", "bar"} - }; - - srv.handler = delegate (HttpListenerContext ctx) - { - if (ctx.Request.HttpMethod != "POST") - { - return "bad method!"; - } - if (ctx.Request.Url.AbsolutePath != "/get_params") - { - return "bad path!"; - } - string expected_body = DuoApi.CanonicalizeParams(parameters); - string actual_body = (new StreamReader(ctx.Request.InputStream)).ReadToEnd(); - if (expected_body != actual_body) - { - return "bad post body!"; - } - - // Sign() is itself tested elsewhere - string date = ctx.Request.Headers["X-Duo-Date"]; - string expected_signature = api.Sign( - "POST", "/get_params", expected_body, date); - string authorization = ctx.Request.Headers["Authorization"]; - if (authorization != expected_signature) - { - return "bad signature"; - } - return "OK"; - - }; - - HttpStatusCode code; - string response = api.ApiCall("POST", "/get_params", parameters, 10000, out code); - Assert.Equal("OK", response); - } - - [Fact] - public void TestPostParameterSigningCustomDate() - { - Dictionary parameters = new Dictionary - { - {"param1", "foo"}, - {"param2", "bar"} - }; - - srv.handler = delegate (HttpListenerContext ctx) - { - if (ctx.Request.HttpMethod != "POST") - { - return "bad method!"; - } - if (ctx.Request.Url.AbsolutePath != "/get_params") - { - return "bad path!"; - } - string expected_body = DuoApi.CanonicalizeParams(parameters); - string actual_body = (new StreamReader(ctx.Request.InputStream)).ReadToEnd(); - if (expected_body != actual_body) - { - return "bad post body!"; - } - - string date_header = ctx.Request.Headers["X-Duo-Date"]; - if (date_header != "Mon, 11 Nov 2013 22:34:00 +0000") - { - return "bad date!"; - } - - // Sign() is itself tested elsewhere - string expected_signature = api.Sign( - "POST", "/get_params", expected_body, date_header); - string authorization = ctx.Request.Headers["Authorization"]; - if (authorization != expected_signature) - { - return "bad signature"; - } - return "OK"; - - }; - - DateTime date = new DateTime(2013, 11, 11, 22, 34, 00, DateTimeKind.Utc); - HttpStatusCode status_code; - string response = api.ApiCall("POST", "/get_params", parameters, 10000, date, out status_code); - Assert.Equal("OK", response); - } - - - [Fact] - public void TestJsonTimeout() - { - srv.handler = delegate (HttpListenerContext ctx) - { - Thread.Sleep(2 * 1000); - return "You should've timed out!"; - }; - - var ex = Record.Exception(() => - { - string response = api.JSONApiCall("GET", "/timeout", new Dictionary(), 500); - }); - - var we = Assert.IsType(ex); - Assert.Equal(WebExceptionStatus.Timeout, we.Status); - - // Free up listener for later tests - srv.listener.Stop(); - } - - [Fact] - public void TestValidJsonResponse() - { - srv.handler = delegate (HttpListenerContext ctx) - { - return "{\"stat\": \"OK\", \"response\": \"hello, world!\"}"; - }; - string response = api.JSONApiCall("GET", "/json_ok", new Dictionary()); - Assert.Equal("hello, world!", response); - } - - [Fact] - public void TestValidJsonPagingResponseNoParameters() - { - srv.handler = delegate (HttpListenerContext ctx) - { - return "{\"stat\": \"OK\", \"response\": \"hello, world!\", \"metadata\": {\"next_offset\":10}}"; - }; - var parameters = new Dictionary(); - var jsonResponse = api.JSONPagingApiCall("GET", "/json_ok", parameters, 0, 10); - Assert.Equal("hello, world!", jsonResponse["response"]); - var metadata = jsonResponse["metadata"] as Dictionary; - Assert.Equal(10, metadata["next_offset"]); - // make sure parameters was not changed as a side-effect - Assert.Empty(parameters); - } - - [Fact] - public void TestValidJsonPagingResponseExistingParameters() - { - srv.handler = delegate (HttpListenerContext ctx) - { - return "{\"stat\": \"OK\", \"response\": \"hello, world!\", \"metadata\": {}}"; - }; - var parameters = new Dictionary() - { - {"offset", "0"}, - {"limit", "10"} - }; - var jsonResponse = api.JSONPagingApiCall("GET", "/json_ok", parameters, 10, 20); - Assert.Equal("hello, world!", jsonResponse["response"]); - var metadata = jsonResponse["metadata"] as Dictionary; - Assert.False(metadata.ContainsKey("next_offset")); - // make sure parameters was not changed as a side-effect - Assert.Equal(2, parameters.Count); - Assert.Equal("0", parameters["offset"]); - Assert.Equal("10", parameters["limit"]); - } - - [Fact] - public void TestErrorJsonResponse() - { - srv.handler = delegate (HttpListenerContext ctx) - { - ctx.Response.StatusCode = 400; - return "{\"stat\": \"FAIL\", \"message\": \"Missing required request parameters\", \"code\": 40001, \"message_detail\": \"user_id or username\"}"; - }; - - var ex = Record.Exception(() => - { - string response = api.JSONApiCall("GET", "/json_error", new Dictionary()); - }); - - Assert.NotNull(ex); - var e = Assert.IsType(ex); - - - Assert.Equal(400, e.HttpStatus); - Assert.Equal(40001, e.Code); - Assert.Equal("Missing required request parameters", e.ApiMessage); - Assert.Equal("user_id or username", e.ApiMessageDetail); - - } - - [Fact] - public void TestJsonResponseMissingField() - { - srv.handler = delegate (HttpListenerContext ctx) - { - ctx.Response.StatusCode = 400; - return "{\"message\": \"Missing required request parameters\", \"code\": 40001, \"message_detail\": \"user_id or username\"}"; - }; - - var ex = Record.Exception(() => - { - string response = api.JSONApiCall("GET", "/json_missing_field", new Dictionary()); - }); - - Assert.NotNull(ex); - var e = Assert.IsType(ex); - - Assert.Equal(400, e.HttpStatus); - - } - - [Fact] - public void TestJsonUnparseableResponse() - { - srv.handler = delegate (HttpListenerContext ctx) - { - ctx.Response.StatusCode = 500; - return "this is not json"; - }; - var ex = Record.Exception(() => - { - string response = api.JSONApiCall("GET", "/json_bad", new Dictionary()); - }); - - Assert.NotNull(ex); - var e = Assert.IsType(ex); - Assert.Equal(500, e.HttpStatus); - - } - - [Fact] - public void TestRateLimitThenSuccess() - { - List statusCodes = new List() { 429, 200 }; - int callCount = 0; - srv.handler = delegate (HttpListenerContext ctx) - { - callCount++; - ctx.Response.StatusCode = statusCodes[0]; - statusCodes.RemoveAt(0); - return "foobar"; - }; - srv.requestsToHandle = 2; - - HttpStatusCode code; - string response = api.ApiCall("GET", "/hello", new Dictionary(), 10000, out code); - - Assert.Equal(HttpStatusCode.OK, code); - Assert.Single(api.sleeper.sleepCalls); - Assert.Equal(1123, api.sleeper.sleepCalls[0]); - Assert.Single(api.random.randomCalls); - Assert.Equal(1001, api.random.randomCalls[0]); - } - - [Fact] - public void TestRateLimitedCompletely() - { - List statusCodes = new List() { 429, 429, 429, 429, 429, 429, 429 }; - int callCount = 0; - srv.handler = delegate (HttpListenerContext ctx) - { - callCount++; - ctx.Response.StatusCode = statusCodes[0]; - statusCodes.RemoveAt(0); - return "foobar"; - }; - srv.requestsToHandle = 7; - - HttpStatusCode code; - string response = api.ApiCall("GET", "/hello", new Dictionary(), 10000, out code); - - Assert.Equal(code, (HttpStatusCode)429); - Assert.Equal(7, callCount); - Assert.Equal(6, api.sleeper.sleepCalls.Count); - Assert.Equal(1123, api.sleeper.sleepCalls[0]); - Assert.Equal(2123, api.sleeper.sleepCalls[1]); - Assert.Equal(4123, api.sleeper.sleepCalls[2]); - Assert.Equal(8123, api.sleeper.sleepCalls[3]); - Assert.Equal(16123, api.sleeper.sleepCalls[4]); - Assert.Equal(32123, api.sleeper.sleepCalls[5]); - - Assert.Equal(6, api.random.randomCalls.Count); - Assert.Equal(1001, api.random.randomCalls[0]); - Assert.Equal(1001, api.random.randomCalls[1]); - Assert.Equal(1001, api.random.randomCalls[2]); - Assert.Equal(1001, api.random.randomCalls[3]); - Assert.Equal(1001, api.random.randomCalls[4]); - Assert.Equal(1001, api.random.randomCalls[5]); - } -} \ No newline at end of file diff --git a/test/CertPinningTest.cs b/test/CertPinningTest.cs deleted file mode 100644 index 9383475..0000000 --- a/test/CertPinningTest.cs +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates - * All rights reserved - */ - -using Duo; -using System; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using Xunit; - -public class CertPinningTestBase -{ - // Helper methods and some hard-coded certificates - protected static X509Certificate2 DuoApiServerCert() - { - // The leaf certificate for api-*.duosecurity.com - return CertFromString(DUO_API_CERT_SERVER); - } - - protected static X509Chain DuoApiChain() - { - // The certificate chain for api-*.duosecurity.com - var chain = new X509Chain(); - // Verify as of a date that the certs are valid for - chain.ChainPolicy.VerificationTime = new DateTime(2023, 01, 01); - chain.ChainPolicy.ExtraStore.Add(CertFromString(DUO_API_CERT_ROOT)); - chain.ChainPolicy.ExtraStore.Add(CertFromString(DUO_API_CERT_INTER)); - bool valid = chain.Build(DuoApiServerCert()); - Assert.True(valid); - return chain; - } - - protected static X509Chain MicrosoftComChain() - { - // A valid chain, but for www.microsoft.com, not Duo - var chain = new X509Chain(); - // Verify as of a date that the certs are valid for - chain.ChainPolicy.VerificationTime = new DateTime(2023, 01, 01); - chain.ChainPolicy.ExtraStore.Add(CertFromString(MICROSOFT_COM_CERT_ROOT)); - chain.ChainPolicy.ExtraStore.Add(CertFromString(MICROSOFT_COM_CERT_INTER)); - bool valid = chain.Build(CertFromString(MICROSOFT_COM_CERT_SERVER)); - Assert.True(valid); - return chain; - } - - protected static X509Certificate2 CertFromString(string certString) - { - return new X509Certificate2(Convert.FromBase64String(certString)); - } - - // Certificates exported from the web site 2022-03-09 - protected const string DUO_API_CERT_SERVER = "MIIH0zCCBrugAwIBAgIQATqA/dmRlE1FQRYim5kzkDANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMS8wLQYDVQQDEyZEaWdpQ2VydCBTSEEyIEhpZ2ggQXNzdXJhbmNlIFNlcnZlciBDQTAeFw0yMjAzMDIwMDAwMDBaFw0yMzA0MDIyMzU5NTlaMGsxCzAJBgNVBAYTAlVTMREwDwYDVQQIEwhNaWNoaWdhbjESMBAGA1UEBxMJQW5uIEFyYm9yMRkwFwYDVQQKExBEdW8gU2VjdXJpdHkgTExDMRowGAYDVQQDDBEqLmR1b3NlY3VyaXR5LmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKGWtT2do8lVflEai4UAdOc019bQyQ4XjUHKVmbHwUxShpmwetLusu4+A0MPLbwZko9kwYCXK8TxrVzABIAWw5CqirJWr80+KucmtFVUgxqFav7vUIJnY/BaWDGkiLSMCLz8HToxi8Fp86rQjTM08AEjPLYMlvU41HlWTvuxQT6HdR0uQovhJ1Qrn4IMlrCoLoPkisPWtVaVX/cMJEqWtT/M89mCiczqNxzE7YMDVwRZowDdmC/T6ujo09mCUkl/uojBlENEiFHKIfjz7SDItDGsFmM0ucRv5Mxfvz80mNkoyRIDK8vL2hq7AzdO3qgjL+ZGT3g9H6YCUqKy16SI/PU6nhS48edpuMB6C1m26dldBszK7foeBqtx59WpztYJAlbClaVDuM88DMYANIiduGn3GY7aTlIXn+lHJh1RoLalCh6aOZs3v/2+ggLeGj75vtqhr91aFJozbgu4OtQZkfSepYt/5dZAHCimuJnNH+euDvWtrj10DQ8ToIXQd0vxQ7QbDY8dMjsGi9vYzL8QPOmF/iTBt5V663K83Kjy1hm/QrKOKbAyak8rsyBYndwEXmCpVEHRI90ECCyVBGzX5pjVEtBP0ZZWem9x6K4kwx1xCQl0mOVdmwro9lBLw3HNAj4/S0R77ZVhgHXgkL2vFozkGSSyYrw6sBfr9685FMSDAgMBAAGjggNsMIIDaDAfBgNVHSMEGDAWgBRRaP+QrwIHdTzM2WVkYqISuFlyOzAdBgNVHQ4EFgQUHANgvmzsw2ofi4cQ9W7b1BJYs20wLQYDVR0RBCYwJIIRKi5kdW9zZWN1cml0eS5jb22CD2R1b3NlY3VyaXR5LmNvbTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMHUGA1UdHwRuMGwwNKAyoDCGLmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9zaGEyLWhhLXNlcnZlci1nNi5jcmwwNKAyoDCGLmh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9zaGEyLWhhLXNlcnZlci1nNi5jcmwwPgYDVR0gBDcwNTAzBgZngQwBAgIwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMIGDBggrBgEFBQcBAQR3MHUwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBNBggrBgEFBQcwAoZBaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0U0hBMkhpZ2hBc3N1cmFuY2VTZXJ2ZXJDQS5jcnQwCQYDVR0TBAIwADCCAX4GCisGAQQB1nkCBAIEggFuBIIBagFoAHcA6D7Q2j71BjUy51covIlryQPTy9ERa+zraeF3fW0GvW4AAAF/TKU0oAAABAMASDBGAiEAhhSnzx392jnLqxjI4OKypVLQ8DD5GLG06IAV7Ajmr8oCIQCWAtF/QHRDtAenfkplcQ4pzNfPGq0WOwHHHygXFg6bxgB1ALNzdwfhhFD4Y4bWBancEQlKeS2xZwwLh9zwAw55NqWaAAABf0ylNQgAAAQDAEYwRAIgBVuyQ38fIBW+GjBE9PbmMvtlyP9HzaF4XigzUNRkrfsCIGJzhTwCpI7UVXYLOXM0jKA3DBIVah06ohtRQSaG/S0wAHYAtz77JN+cTbp18jnFulj0bF38Qs96nzXEnh0JgSXttJkAAAF/TKU02QAABAMARzBFAiEAyHdWYdIzJUzcvaqOsSThLtBuVtFlGpIWHmzg9gZlf1MCIEUr9IZ7zXAs+6sD/j9T4GMgwJxoKvns7aM+qvRjvh3qMA0GCSqGSIb3DQEBCwUAA4IBAQAp5YyMInyd7dik2lkQ09rugqVY+8idT9QKcEF1OzwcsSNg1RiHJg3lpTjRrG7EBvPghaPhAeWIDnpoeqXroixvp/pIBbWxSJX7a4Zzu7HTGHARbxkN5+wmIsXV+zq0FK/uKi74B5slaeXGGIhUnNpFt9E1IBW8425G0FkVb0A5/paEwEZFqhSOWgxclwqGqMdqIY9jYCTkHdV5YU5hw/yBQPy9eNBwV/jRu92+1iEdwYvQHi6O+Lb+xGQwPHeVEIwbdZ3B1ZeQlIlkMhPYF12R0862VQ8SKLFNdr1i8cgt4PraFKwV2PZ0JWFE9DU/9jfk1ZSyXtEn6miu4GXef4sH"; - - // Certificates exported from the web sites 2021-09-22 - protected const string DUO_API_CERT_INTER = "MIIEsTCCA5mgAwIBAgIQBOHnpNxc8vNtwCtCuF0VnzANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTEzMTAyMjEyMDAwMFoXDTI4MTAyMjEyMDAwMFowcDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEvMC0GA1UEAxMmRGlnaUNlcnQgU0hBMiBIaWdoIEFzc3VyYW5jZSBTZXJ2ZXIgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC24C/CJAbIbQRf1+8KZAayfSImZRauQkCbztyfn3YHPsMwVYcZuU+UDlqUH1VWtMICKq/QmO4LQNfE0DtyyBSe75CxEamu0si4QzrZCwvV1ZX1QK/IHe1NnF9Xt4ZQaJn1itrSxwUfqJfJ3KSxgoQtxq2lnMcZgqaFD15EWCo3j/018QsIJzJa9buLnqS9UdAn4t07QjOjBSjEuyjMmqwrIw14xnvmXnG3Sj4I+4G3FhahnSMSTeXXkgisdaScus0Xsh5ENWV/UyU50RwKmmMbGZJ0aAo3wsJSSMs5WqK24V3B3aAguCGikyZvFEohQcftbZvySC/zA/WiaJJTL17jAgMBAAGjggFJMIIBRTASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wSwYDVR0fBEQwQjBAoD6gPIY6aHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0SGlnaEFzc3VyYW5jZUVWUm9vdENBLmNybDA9BgNVHSAENjA0MDIGBFUdIAAwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAdBgNVHQ4EFgQUUWj/kK8CB3U8zNllZGKiErhZcjswHwYDVR0jBBgwFoAUsT7DaQP4v0cB1JgmGggC72NkK8MwDQYJKoZIhvcNAQELBQADggEBABiKlYkD5m3fXPwdaOpKj4PWUS+Na0QWnqxj9dJubISZi6qBcYRb7TROsLd5kinMLYBq8I4g4Xmk/gNHE+r1hspZcX30BJZr01lYPf7TMSVcGDiEo+afgv2MW5gxTs14nhr9hctJqvIni5ly/D6q1UEL2tU2ob8cbkdJf17ZSHwD2f2LSaCYJkJA69aSEaRkCldUxPUd1gJea6zuxICaEnL6VpPX/78whQYwvwt/Tv9XBZ0k7YXDK/umdaisLRbvfXknsuvCnQsH6qqF0wGjIChBWUMo0oHjqvbsezt3tkBigAVBRQHvFwY+3sAzm2fTYS5yh+Rp/BIAV0AecPUeybQ="; - protected const string DUO_API_CERT_ROOT = "MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCevEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K"; - - // Certificates exported from the web sites 2022-08-19 - protected const string MICROSOFT_COM_CERT_SERVER = "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"; - protected const string MICROSOFT_COM_CERT_INTER = "MIIFWjCCBEKgAwIBAgIQDxSWXyAgaZlP1ceseIlB4jANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTIwMDcyMTIzMDAwMFoXDTI0MTAwODA3MDAwMFowTzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEgMB4GA1UEAxMXTWljcm9zb2Z0IFJTQSBUTFMgQ0EgMDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCqYnfPmmOyBoTzkDb0mfMUUavqlQo7Rgb9EUEf/lsGWMk4bgj8T0RIzTqk970eouKVuL5RIMW/snBjXXgMQ8ApzWRJCZbar879BV8rKpHoAW4uGJssnNABf2n17j9TiFy6BWy+IhVnFILyLNK+W2M3zK9gheiWa2uACKhuvgCca5Vw/OQYErEdG7LBEzFnMzTmJcliW1iCdXby/vI/OxbfqkKD4zJtm45DJvC9Dh+hpzqvLMiK5uo/+aXSJY+SqhoIEpz+rErHw+uAlKuHFtEjSeeku8eR3+Z5ND9BSqc6JtLqb0bjOHPm5dSRrgt4nnil75bjc9j3lWXpBb9PXP9Sp/nPCK+nTQmZwHGjUnqlO9ebAVQD47ZisFonnDAmjrZNVqEXF3p7laEHrFMxttYuD81BdOzxAbL9Rb/8MeFGQjE2Qx65qgVfhH+RsYuuD9dUw/3wZAhq05yO6nk07AM9c+AbNtRoEcdZcLCHfMDcbkXKNs5DJncCqXAN6LhXVERCw/usG2MmCMLSIx9/kwt8bwhUmitOXc6fpT7SmFvRAtvxg84wUkg4Y/Gx++0j0z6StSeN0EJz150jaHG6WV4HUqaWTb98Tm90IgXAU4AW2GBOlzFPiU5IY9jt+eXC2Q6yC/ZpTL1LAcnL3Qa/OgLrHN0wiw1KFGD51WRPQ0Sh7QIDAQABo4IBJTCCASEwHQYDVR0OBBYEFLV2DDARzseSQk1Mx1wsyKkM6AtkMB8GA1UdIwQYMBaAFOWdWTCCR1jMrPoIVDaGezq1BE3wMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vT21uaXJvb3QyMDI1LmNybDAqBgNVHSAEIzAhMAgGBmeBDAECATAIBgZngQwBAgIwCwYJKwYBBAGCNyoBMA0GCSqGSIb3DQEBCwUAA4IBAQCfK76SZ1vae4qt6P+dTQUO7bYNFUHR5hXcA2D59CJWnEj5na7aKzyowKvQupW4yMH9fGNxtsh6iJswRqOOfZYC4/giBO/gNsBvwr8uDW7t1nYoDYGHPpvnpxCM2mYfQFHq576/TmeYu1RZY29C4w8xYBlkAA8mDJfRhMCmehk7cN5FJtyWRj2cZj/hOoI45TYDBChXpOlLZKIYiG1giY16vhCRi6zmPzEwv+tk156N6cGSVm44jTQ/rs1sa0JSYjzUaYngoFdZC4OfxnIkQvUIA4TOFmPzNPEFdjcZsgbeEz4TcGHTBPK4R28F44qIMCtHRV55VMX53ev6P3hRddJb"; - protected const string MICROSOFT_COM_CERT_ROOT = "MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoXDTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9yZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVyVHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKrmD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjrIZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeKmpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSuXmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZydc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/yejl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT929hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3WgxjkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhzksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLSR9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp"; -} - -public class CertPinningTest : CertPinningTestBase -{ - - private RemoteCertificateValidationCallback duoPinner; - public CertPinningTest() - { - duoPinner = CertificatePinnerFactory.GetDuoCertificatePinner(); - } - - [Fact] - public void TestReadCertFile() - { - Assert.Equal(10, CertificatePinnerFactory.ReadCertsFromFile().Length); - } - - [Fact] - public void TestSuccess() - { - Assert.True(duoPinner(null, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.None)); - } - - [Fact] - public void TestNullCertificate() - { - Assert.False(duoPinner(null, null, DuoApiChain(), SslPolicyErrors.None)); - } - - [Fact] - public void TestNullChain() - { - Assert.False(duoPinner(null, DuoApiServerCert(), null, SslPolicyErrors.None)); - } - - [Fact] - public void TestFatalSslError() - { - Assert.False(duoPinner(null, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.RemoteCertificateNameMismatch)); - } - - [Fact] - public void TestUnmatchedRoot() - { - Assert.False(duoPinner(null, DuoApiServerCert(), MicrosoftComChain(), SslPolicyErrors.None)); - } - - [Fact] - public void TestAlternateCertsSuccess() - { - var certCollection = new X509Certificate2Collection - { - CertFromString(MICROSOFT_COM_CERT_ROOT) - }; - - var pinner = new CertificatePinnerFactory(certCollection).GetPinner(); - - Assert.True(pinner(null, CertFromString(MICROSOFT_COM_CERT_SERVER), MicrosoftComChain(), SslPolicyErrors.None)); - } -} - -public class CertDisablingTest : CertPinningTestBase -{ - private RemoteCertificateValidationCallback pinner; - - public CertDisablingTest() - { - pinner = CertificatePinnerFactory.GetCertificateDisabler(); - } - - [Fact] - public void TestSuccess() - { - Assert.True(pinner(null, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.None)); - } - - [Fact] - public void TestNullCertificate() - { - Assert.True(pinner(null, null, DuoApiChain(), SslPolicyErrors.None)); - } - - [Fact] - public void TestNullChain() - { - Assert.True(pinner(null, DuoApiServerCert(), null, SslPolicyErrors.None)); - } - - [Fact] - public void TestFatalSslError() - { - Assert.True(pinner(null, DuoApiServerCert(), DuoApiChain(), SslPolicyErrors.RemoteCertificateNameMismatch)); - } - - [Fact] - public void TestUnmatchedRoot() - { - Assert.True(pinner(null, DuoApiServerCert(), MicrosoftComChain(), SslPolicyErrors.None)); - } -} \ No newline at end of file diff --git a/test/DuoApiTest.csproj b/test/DuoApiTest.csproj deleted file mode 100644 index 9aaa12f..0000000 --- a/test/DuoApiTest.csproj +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - Debug - AnyCPU - {6B97B9FB-E553-494C-BD50-4BF7DB5C2184} - Library - Properties - DuoApiTest - DuoApiTest - v4.8 - 512 - - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - false - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - false - - - - ..\packages\Microsoft.Bcl.AsyncInterfaces.6.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll - - - - ..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll - - - ..\packages\System.Memory.4.5.4\lib\net461\System.Memory.dll - - - - ..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll - - - ..\packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll - - - ..\packages\System.Text.Encodings.Web.6.0.0\lib\net461\System.Text.Encodings.Web.dll - - - ..\packages\System.Text.Json.6.0.2\lib\net461\System.Text.Json.dll - - - ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll - - - ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll - - - - - - - ..\packages\xunit.abstractions.2.0.3\lib\net35\xunit.abstractions.dll - - - ..\packages\xunit.assert.2.4.1\lib\netstandard1.1\xunit.assert.dll - - - ..\packages\xunit.extensibility.core.2.4.1\lib\net452\xunit.core.dll - - - ..\packages\xunit.extensibility.execution.2.4.1\lib\net452\xunit.execution.desktop.dll - - - - - - - - - - - - - - - - - - - {6e96c9d9-0825-4d26-83c7-8a62180f8fb9} - duo_api_csharp - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - - - - - \ No newline at end of file diff --git a/test/Makefile b/test/Makefile deleted file mode 100644 index 4d6e7b5..0000000 --- a/test/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -DLLS := SigningTest.dll QueryParamsTest.dll - -test: $(DLLS) - nunit-console $^ - -%.dll: %.cs ../Duo.cs - dmcs /target:library -r:System.Web.Services -r:System.Web.Extensions -r:System.Web -r:nunit.framework.dll $< ../Duo.cs -out:$@ - -clean: - rm -f $(DLLS) *~ TestResult.xml diff --git a/test/Properties/AssemblyInfo.cs b/test/Properties/AssemblyInfo.cs deleted file mode 100644 index 80f68e1..0000000 --- a/test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("DuoApiTest")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Cisco Systems")] -[assembly: AssemblyProduct("DuoApiTest")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("ee1b0852-a526-4b1f-bbda-178c88e8e2fa")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/test/QueryParamsTest.cs b/test/QueryParamsTest.cs deleted file mode 100644 index 70956bb..0000000 --- a/test/QueryParamsTest.cs +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates - * All rights reserved - */ - -using Duo; -using System.Collections.Generic; - -using Xunit; - -public class QueryParamsTest -{ - [Fact] - public void Simple() - { - var parameters = new Dictionary - { - {"username", "root"}, - {"realname", "First Last"}, - }; - var expected = "realname=First%20Last&username=root"; - Assert.Equal(expected, DuoApi.CanonicalizeParams(parameters)); - } - - [Fact] - public void ZeroParams() - { - var parameters = new Dictionary(); - Assert.Equal("", DuoApi.CanonicalizeParams(parameters)); - } - - [Fact] - public void OneParam() - { - var parameters = new Dictionary - { - {"realname", "First Last"}, - }; - var expected = "realname=First%20Last"; - Assert.Equal(expected, DuoApi.CanonicalizeParams(parameters)); - } - - [Fact] - public void PrintableASCII() - { - var parameters = new Dictionary - { - {"digits", "0123456789"}, - {"letters", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"}, - {"punctuation", "!\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"}, - {"whitespace", "\t\n\u000b\u000c\r "}, - }; - var expected = "digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20"; - Assert.Equal(expected, DuoApi.CanonicalizeParams(parameters)); - } - - [Fact] - public void UnicodeFuzzValues() - { - var parameters = new Dictionary - { - {"bar", "\u2815\uaaa3\u37cf\u4bb7\u36e9\ucc05\u668e\u8162\uc2bd\ua1f1"}, - {"baz", "\u0df3\u84bd\u5669\u9985\ub8a4\uac3a\u7be7\u6f69\u934a\ub91c"}, - {"foo", "\ud4ce\ud6d6\u7938\u50c0\u8a20\u8f15\ufd0b\u8024\u5cb3\uc655"}, - {"qux", "\u8b97\uc846-\u828e\u831a\uccca\ua2d4\u8c3e\ub8b2\u99be"}, - }; - var expected = "bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE"; - Assert.Equal(expected, DuoApi.CanonicalizeParams(parameters)); - } - - [Fact] - public void UnicodeFuzzKeysAndValues() - { - var parameters = new Dictionary - { - {"\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0"}, - {"\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30"}, - {"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3"}, - {"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"}, - }; - var expected = "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU"; - Assert.Equal(expected, DuoApi.CanonicalizeParams(parameters)); - } - - [Fact] - public void SortOrderWithCommonPrefix() - { - var parameters = new Dictionary - { - {"foo_bar", "2"}, - {"foo", "1"}, - }; - var expected = "foo=1&foo_bar=2"; - Assert.Equal(expected, DuoApi.CanonicalizeParams(parameters)); - } - - [Fact] - public void NextOffsetTest() - { - string[] offset = new string[] { "fjaewoifjew", "473891274832917498" }; - var parameters = new Dictionary - { - {"next_offset", offset}, - {"foo", "1"}, - }; - var expected = "foo=1&next_offset=fjaewoifjew&next_offset=473891274832917498"; - Assert.Equal(expected, DuoApi.CanonicalizeParams(parameters)); - } -} diff --git a/test/SigningTest.cs b/test/SigningTest.cs deleted file mode 100644 index 4c5d933..0000000 --- a/test/SigningTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates - * All rights reserved - */ - -using Duo; -using System.Collections.Generic; - -using Xunit; -public class SigningTest -{ - [Fact] - public void HmacSha512() - { - var ikey = "test_ikey"; - var skey = "gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT"; - var host = "foO.BAr52.cOm"; - var client = new Duo.DuoApi(ikey, skey, host); - var method = "PoSt"; - var path = "/Foo/BaR2/qux"; - var date = "Fri, 07 Dec 2012 17:18:00 -0000"; - var parameters = new Dictionary - { - {"\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\xc8\uc170", "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0"}, - {"\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813", "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30"}, - {"\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042", "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3"}, - {"\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934", "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU"}, - }; - string canon_params = DuoApi.CanonicalizeParams(parameters); - var actual = client.Sign(method, path, canon_params, date); - var expected = "Basic dGVzdF9pa2V5OjA1MDgwNjUwMzVhMDNiMmExZGUyZjQ1M2U2MjllNzkxZDE4MDMyOWUxNTdmNjVkZjZiM2UwZjA4Mjk5ZDQzMjFlMWM1YzdhN2M3ZWU2YjllNWZjODBkMWZiNmZiZjNhZDVlYjdjNDRkZDNiMzk4NWEwMmMzN2FjYTUzZWMzNjk4"; - Assert.Equal(expected, actual); - } -} diff --git a/test/app.config b/test/app.config deleted file mode 100644 index 1696df6..0000000 --- a/test/app.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/test/packages.config b/test/packages.config deleted file mode 100644 index 9462602..0000000 --- a/test/packages.config +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file