From 364ef9d891996d36f49db3764486806a8981a41a Mon Sep 17 00:00:00 2001 From: Chris Bacon Date: Thu, 1 Jun 2017 13:00:49 +0100 Subject: [PATCH] Merge v127wip branch into master (#1033) * Provide synchronous access to default credentials (#1018) Fixes #652 * Validate application name (#1019) Fixes #729. * Add download method that returns final download status (#1020) Fixes #982. * Support range downloads in generated code (#1021) Fixes #981 * Unregister CancellationToken, and fix exception handling on cancellation (#1023) Fixes #1022 * Clarify NET Core support (#1027) Fixes #1025 * Update version v1.26.2 -> v1.27.0 (#1028) * Support JWT validation (#1026) Fixes #920 * Real JWT validation integration test (#1032) --- .../languages/csharp/default/features.json | 4 +- .../___package_name___.csproj.tmpl | 2 +- .../csharp/default/templates/_method.tmpl | 32 ++- README.md | 8 +- Src/Support/CommonProjectProperties.xml | 2 +- .../GoogleJsonWebSignatureTests.cs | 155 +++++++++++++++ Src/Support/Google.Apis.Auth/AssemblyInfo.cs | 1 + .../Google.Apis.Auth/Google.Apis.Auth.csproj | 2 +- .../GoogleJsonWebSignature.cs | 185 +++++++++++++++++- .../Google.Apis.Auth/InvalidJwtException.cs | 32 +++ .../OAuth2/GoogleAuthConsts.cs | 3 + .../OAuth2/GoogleCredential.cs | 10 + .../OAuth2/LocalServerCodeReceiver.cs | 27 +-- .../Google.Apis.Core/Google.Apis.Core.csproj | 2 +- .../Apis/Services/BaseClientServiceTest.cs | 33 ++++ Src/Support/Google.Apis/Google.Apis.csproj | 2 +- .../Google.Apis/Services/BaseClientService.cs | 9 + .../IntegrationTests/AuthUriConstsTests.cs | 1 + .../GoogleJsonWebSignatureTests.cs | 87 ++++++++ .../IntegrationTests.Utils/Helper.cs | 3 + 20 files changed, 575 insertions(+), 25 deletions(-) create mode 100644 Src/Support/Google.Apis.Auth.Tests/GoogleJsonWebSignatureTests.cs create mode 100644 Src/Support/Google.Apis.Auth/InvalidJwtException.cs create mode 100644 Src/Support/IntegrationTests/GoogleJsonWebSignatureTests.cs diff --git a/ClientGenerator/src/googleapis/codegen/languages/csharp/default/features.json b/ClientGenerator/src/googleapis/codegen/languages/csharp/default/features.json index 210c918195..aed5e37df8 100644 --- a/ClientGenerator/src/googleapis/codegen/languages/csharp/default/features.json +++ b/ClientGenerator/src/googleapis/codegen/languages/csharp/default/features.json @@ -1,8 +1,8 @@ { "language": "csharp", "description": "C# libraries for Google APIs.", - "releaseVersion": "1.26.2", "comment1": "Version of generated package.", - "currentSupportVersion": "1.26.2", "comment2": "Version of support library upon which to depend.", + "releaseVersion": "1.27.0", "comment1": "Version of generated package.", + "currentSupportVersion": "1.27.0", "comment2": "Version of support library upon which to depend.", "pclSupportVersion": "1.25.0", "comment3": "Version of PCL support library.", "net40SupportVersion": "1.10.0", "comment4": "Version of net40 support library." } diff --git a/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/___package_name___/___package_name___.csproj.tmpl b/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/___package_name___/___package_name___.csproj.tmpl index 00f397fe7b..5f73a9a145 100644 --- a/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/___package_name___/___package_name___.csproj.tmpl +++ b/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/___package_name___/___package_name___.csproj.tmpl @@ -16,7 +16,7 @@ Supported Platforms: - .NET Framework 4.5+ - - NetStandard1.3 + - NetStandard1.3, providing .NET Core support. Legacy platforms: - .NET Framework 4.0 diff --git a/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/_method.tmpl b/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/_method.tmpl index 3f0545d5ee..1afa831255 100644 --- a/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/_method.tmpl +++ b/ClientGenerator/src/googleapis/codegen/languages/csharp/default/templates/_method.tmpl @@ -133,12 +133,22 @@ public override string RestPath /// Gets the media downloader. public Google.Apis.Download.IMediaDownloader MediaDownloader { get; private set; } -/// Synchronously download the media into the given stream. +/// +/// Synchronously download the media into the given stream. +/// Warning: This method hides download errors; use instead. +/// public virtual void Download(System.IO.Stream stream) { MediaDownloader.Download(this.GenerateRequestUri(), stream); } +/// Synchronously download the media into the given stream. +/// The final status of the download; including whether the download succeeded or failed. +public virtual Google.Apis.Download.IDownloadProgress DownloadWithStatus(System.IO.Stream stream) +{ + return MediaDownloader.Download(this.GenerateRequestUri(), stream); +} + /// Asynchronously download the media into the given stream. public virtual System.Threading.Tasks.Task DownloadAsync(System.IO.Stream stream) { @@ -151,6 +161,26 @@ public virtual System.Threading.Tasks.TaskSynchronously download a range of the media into the given stream. +public virtual Google.Apis.Download.IDownloadProgress DownloadRange(System.IO.Stream stream, System.Net.Http.Headers.RangeHeaderValue range) +{ + var mediaDownloader = new Google.Apis.Download.MediaDownloader(Service); + mediaDownloader.Range = range; + return mediaDownloader.Download(this.GenerateRequestUri(), stream); +} + +/// Asynchronously download a range of the media into the given stream. +public virtual System.Threading.Tasks.Task DownloadRangeAsync(System.IO.Stream stream, + System.Net.Http.Headers.RangeHeaderValue range, + System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) +{ + var mediaDownloader = new Google.Apis.Download.MediaDownloader(Service); + mediaDownloader.Range = range; + return mediaDownloader.DownloadAsync(this.GenerateRequestUri(), stream, cancellationToken); +} +#endif {% endif %}{% endindent %} } diff --git a/README.md b/README.md index 746c7af8cc..b3bfa6fd9b 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,12 @@ The Google API client library for .NET enables access to Google APIs such as Dri ## Supported Frameworks -* .NET Framework 4.5 and 4.6 -* .NET Core (via netstandard1.3 support) +* .NET Framework 4.5+ +* netstandard1.3, providing .NET Core support + +### ASP.NET Core + +ASP.NET Core is supported through the `netstandard1.3` framework target. However there are currently no authentication helpers or examples specific to ASP.NET Core, making authentication more difficult to use than on other platforms. This is being tracked in issue [#933](https://github.com/google/google-api-dotnet-client/issues/933). ## Legacy Supported Frameworks diff --git a/Src/Support/CommonProjectProperties.xml b/Src/Support/CommonProjectProperties.xml index 2f9e108d51..efb4601094 100644 --- a/Src/Support/CommonProjectProperties.xml +++ b/Src/Support/CommonProjectProperties.xml @@ -2,7 +2,7 @@ - 1.26.2 + 1.27.0 Google Inc. Copyright 2017 Google Inc. Google diff --git a/Src/Support/Google.Apis.Auth.Tests/GoogleJsonWebSignatureTests.cs b/Src/Support/Google.Apis.Auth.Tests/GoogleJsonWebSignatureTests.cs new file mode 100644 index 0000000000..6042bc3d23 --- /dev/null +++ b/Src/Support/Google.Apis.Auth.Tests/GoogleJsonWebSignatureTests.cs @@ -0,0 +1,155 @@ +/* +Copyright 2017 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Tests.Mocks; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Google.Apis.Auth.Tests +{ + public class GoogleJsonWebSignatureTests + { + // From https://www.googleapis.com/oauth2/v3/certs + const string GoogleCertsJson = @" +{ + ""keys"": [ + { + ""kty"": ""RSA"", + ""alg"": ""RS256"", + ""use"": ""sig"", + ""kid"": ""3c066add5889b989e9c49803c21fa4b29d1f4ead"", + ""n"": ""h3oJdAYVp3eUXPb2SGvCvEgm_KiRfFrL3cMEdT3I-uKBptz5LklVrOflDJurCXjIQF-891MF6JSJjYc9csiJbUNb-jFXkEI9toOa0ynF-Q9KHrUknfn-R2UrhqWMZw0Xe2WTWS12tjEGEa3Kzf7mcrHV_ARW9vv75PhOMa0dPjgerNJyfDrdTMxOjEzOALHJwjXwzsZxxBiox6ZIXKSGGgC99OOiKZvg6erHP7lEADm0Ws9H7lue3dkv50rpaJBPIUJlYKrx82MnxMAy2NAGqrghjQ5nRdB1mBn4F260lEOwcYNu8aGZ7NhD7yjS_xkECamTYwLGe-9PH8bR54aSxQ"", + ""e"": ""AQAB"" + }, + { + ""kty"": ""RSA"", + ""alg"": ""RS256"", + ""use"": ""sig"", + ""kid"": ""c9b39c24ed54a2b1aef3e572d4e411fe5ccf697f"", + ""n"": ""zQM50VGJeWRq_hLYKM8dZSVWNTVygDoACA7au37sA7tkNoYhGUopS9veabFpt5MB_h-zEAGhnNpjxRvqVvrvHi5KpfzSPUJxMR9K-YQlqObpb31eebxCiAa2ssewCWslZ2BZ2ID839_YBtbaSZymq5aBgjf07PuWUy55piuaTOuDCJOCPGXvBhMTTFcBNC3zDOZXpkbbG6h6mk5LTPyXzJ322cPjotCZXNF3FsWbFDcCtacr8ZSEMcCGFmrawmko7BQ62FDQtOVGNK94xaTTwtjnxZn1sFIeABIoo8N-x4zbSaZVX9PVQUjtVsX4V5hHiZqmIhcR62h8vj9tVDwxqQ"", + ""e"": ""AQAB"" + }, + { + ""kty"": ""RSA"", + ""alg"": ""RS256"", + ""use"": ""sig"", + ""kid"": ""f9079a9ea417bb3c4f5be2805fd9d0aa774609d0"", + ""n"": ""lYnlj0CJBnwz_7h5MPUCWpdwXfIxo09_ny2nVEhtZHnaIkpyrtVaUtofs62F8rJAgKN21NrGouIEbiV2i0upO9jffYhSKieZKleM8unuyret3o7Vp3Tme68GEh3ZuSqhyKsia28o5zYy3NzT9Ptt5nZjIk0uTShelHsEV_cGJRUBNmcjJxnKkrSXOABd_CW34GuAZewhfgFWibhulbb6zpNogKB6uv2IIIYF8KaIKyvIwttgyDBSTvgxFxrxWZonTTaB0Ktru2KAyvzoAzZdfnMndHcax6p5D3FnrhNtg4WBxsi8lRO6mz2KrvVYu4wMTD0e4gSPFAhqT8Z-fa7Hcw"", + ""e"": ""AQAB"" + }, + { + ""kty"": ""RSA"", + ""alg"": ""RS256"", + ""use"": ""sig"", + ""kid"": ""bbd2c788a9aaa01b7b8ba763f8fae7f88ef57002"", + ""n"": ""4f6HXWnlVHL58_VeBC4SjmWNelqVpmpGGIeYKKuee-rV1Y-tQXRHSXT-gDHovz6ZslE6f73CCrYvJ-7223o6ZLa1VTB_5sS5rtcqYK2I7VN7H1gvRAw6rWcwruVjQXzruArnjL00ousC8pKXqoXYtlTyYY0T7J_W2nn_imwk7fCRwe48CCMKi6iDJxaJzEc0Gu5tNPzQoFHez-1ohF8DRxFN6IVV6TEVUMpn9OG-BP7aWfSi5WxnND1IcVnLuLDatqltDzRHzZNYnRkLIVlDzz2pEledY281kE5eQZ0vLjGJ2OR-cEd9SBjbh9EMvig3k7-Co6EAuzpIDXBeJDJ21w"", + ""e"": ""AQAB"" + } + ] +}"; + + // From procedure outlined in https://developers.google.com/identity/protocols/OpenIDConnect + // This JWT is valid only from 2017-05-31 10:23 until 2017-05-31 11:23 (UTC), so is not usable. + const string JwtGoogleSigned = + "eyJhbGciOiJSUzI1NiIsImtpZCI6ImJiZDJjNzg4YTlhYWEwMWI3YjhiYTc2M2Y4ZmFlN2Y4OGVmNTcwMDIifQ.eyJhenAiOiIy" + + "MzM3NzIyODE0MjUtYWIybWNiaXFtdjhraDBtZG5xc3Jrcm9kOTdxazM3aDAuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJ" + + "hdWQiOiIyMzM3NzIyODE0MjUtYWIybWNiaXFtdjhraDBtZG5xc3Jrcm9kOTdxazM3aDAuYXBwcy5nb29nbGV1c2VyY29udGVudC" + + "5jb20iLCJzdWIiOiIxMDkwODEwMjMzMDk1NTcxOTcyMTIiLCJoZCI6Imdvb2dsZS5jb20iLCJlbWFpbCI6ImNocmlzYmFjb25AZ" + + "29vZ2xlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiNFJHRTFzVko4WXhqRUtUaGhFZDZpdyIsImlzcyI6" + + "Imh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbSIsImlhdCI6MTQ5NjIyNjIzMCwiZXhwIjoxNDk2MjI5ODMwfQ.Opw9C1ma5eww" + + "HmLrmT6to41U0Tpt0fN2A3Vw9jqgS72iRFq0SDQ4r182uwIvhSUo5MnifY4JheeHBuRtDpiQYFtnh_JLzxiAkkIGCMkMf_7Pr6Q" + + "MWuNsx1ugSFygppvlC_fSEK3LS5P2nUtRFqtR7kR9T1MJN11G5dGjLZnmerot8jdqUo7w_zxaiG-5KK-5z3xGRtcPGvl-04RUU7" + + "qsktkVV3AgLuC_TYQGuVH59sfInCEuMZzJJ219MA1c03F0tbDXCMPSWwXgNj4OXaV7QdP7X6sE0AVBK9WVByApI-4CTL7U4G40z" + + "yXSOZ4DQWYiYkNf7Hqw9foa87U0sZS6eQ"; + // A valid JWT, with a valid signature, but not signed by Google. + // This JWT is valid only from 2017-05-29 19:02 until 2017-05-29 20:02 (UTC), so is not usable. + const string JwtNonGoogleSigned = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJmb3J0ZXN0aW5nQGNocmlzYmFjb24tdGVzdGluZy5pYW0uZ3Nlcn" + + "ZpY2VhY2NvdW50LmNvbSIsInN1YiI6ImZvcnRlc3RpbmdAY2hyaXNiYWNvbi10ZXN0aW5nLmlhbS5nc2VydmljZWFjY291bnQuY" + + "29tIiwiYXVkIjoiaHR0cHM6Ly93d3cuZXhhbXBsZS5jb20vIiwiZXhwIjoxNDk2MDg4MTYwLCJpYXQiOjE0OTYwODQ1NjB9.UE0" + + "oO1QC90rQSmjrWouxUcLV-jay9lnzDraPFbqpcoLcZRyXzG7vUSUVqoipzHweyRyR9bazn_bWCuLuTm9sD-UmokDQGBxqpkGwHJ" + + "vjZ9R6B25vlB5Dqk5QJsPz8Cy_N1wsmeFi41Mn0UsVH1OoQmZHmFgwq61QP1nfzVLlz92sRcEgArJEEM3jwxfFEZVJVckeTqpvA" + + "IMAj-lTi0ysjiKnd7OJwG_HnNqF-nWTU7z0JbZm_l6_Zfyp_wra78YIbDY-VmxBjiz32RDugLqilfhH4o--GThjhdlyHZENMtk-" + + "pO3CE8RfNI5fGnmfUgtf6tcdhk2MiA1Quy8BgB_F5Q"; + + [Fact] + public async Task Validate_Signature() + { + var clockValid1 = new MockClock() { UtcNow = new DateTime(2017, 5, 31, 10, 24, 0, DateTimeKind.Utc) }; + Assert.NotNull(await GoogleJsonWebSignature.ValidateInternalAsync(JwtGoogleSigned, clockValid1, false, GoogleCertsJson)); + + var clockValid2 = new MockClock() { UtcNow = new DateTime(2017, 5, 31, 10, 24, 0, DateTimeKind.Utc) }; + var ex = await Assert.ThrowsAsync(() => + GoogleJsonWebSignature.ValidateInternalAsync(JwtNonGoogleSigned, clockValid2, false, GoogleCertsJson)); + Assert.Equal("JWT invalid: unable to verify signature.", ex.Message); + } + + [Fact] + public async Task Validate_Time() + { + + var clockInvalid1 = new MockClock() { UtcNow = new DateTime(2017, 5, 31, 10, 22, 0, DateTimeKind.Utc) }; + var clockValid1 = new MockClock() { UtcNow = new DateTime(2017, 5, 31, 10, 24, 0, DateTimeKind.Utc) }; + var clockValid2 = new MockClock() { UtcNow = new DateTime(2017, 5, 31, 11, 22, 0, DateTimeKind.Utc) }; + var clockInvalid2 = new MockClock() { UtcNow = new DateTime(2017, 5, 31, 11, 24, 0, DateTimeKind.Utc) }; + + Assert.NotNull(await GoogleJsonWebSignature.ValidateInternalAsync(JwtGoogleSigned, clockValid1, false, GoogleCertsJson)); + Assert.NotNull(await GoogleJsonWebSignature.ValidateInternalAsync(JwtGoogleSigned, clockValid2, false, GoogleCertsJson)); + + var ex1 = await Assert.ThrowsAsync(() => + GoogleJsonWebSignature.ValidateInternalAsync(JwtGoogleSigned, clockInvalid1, false, GoogleCertsJson)); + Assert.Equal("JWT is not yet valid.", ex1.Message); + + var ex2 = await Assert.ThrowsAsync(() => + GoogleJsonWebSignature.ValidateInternalAsync(JwtGoogleSigned, clockInvalid2, false, GoogleCertsJson)); + Assert.Equal("JWT has expired.", ex2.Message); + } + + [Fact] + public async Task Validate_BadJwt() + { + // Null JWT + await Assert.ThrowsAsync(() => GoogleJsonWebSignature.ValidateInternalAsync(null, null, false, GoogleCertsJson)); + // Empty JWT + await Assert.ThrowsAsync(() => GoogleJsonWebSignature.ValidateInternalAsync("", null, false, GoogleCertsJson)); + // Too long JWT + await Assert.ThrowsAsync(() => + GoogleJsonWebSignature.ValidateInternalAsync(new string('a', GoogleJsonWebSignature.MaxJwtLength + 1), null, false, GoogleCertsJson)); + // JWT with incorrect top-level structure; missing signature + await Assert.ThrowsAsync(() => + GoogleJsonWebSignature.ValidateInternalAsync("header.payload", null, false, GoogleCertsJson)); + } + + [Fact] + public async Task Validate_CertCache() + { + var clock1 = new MockClock() { UtcNow = new DateTime(2017, 5, 31, 11, 24, 0, DateTimeKind.Utc) }; + var clock2Cached = new MockClock() { UtcNow = clock1.UtcNow + GoogleJsonWebSignature.CertCacheRefreshInterval - TimeSpan.FromSeconds(1) }; + var clock3Uncached = new MockClock() { UtcNow = clock1.UtcNow + GoogleJsonWebSignature.CertCacheRefreshInterval + TimeSpan.FromSeconds(1) }; + + var rsas1 = await GoogleJsonWebSignature.GetGoogleCertsAsync(clock1, false, GoogleCertsJson); + var rsas2Cached = await GoogleJsonWebSignature.GetGoogleCertsAsync(clock2Cached, false, GoogleCertsJson); + var rsas3Refreshed = await GoogleJsonWebSignature.GetGoogleCertsAsync(clock3Uncached, false, GoogleCertsJson); + var rsas4Forced = await GoogleJsonWebSignature.GetGoogleCertsAsync(clock3Uncached, true, GoogleCertsJson); + + Assert.NotNull(rsas1); + Assert.Same(rsas1, rsas2Cached); + Assert.NotSame(rsas1, rsas3Refreshed); + Assert.NotSame(rsas3Refreshed, rsas4Forced); + } + } +} diff --git a/Src/Support/Google.Apis.Auth/AssemblyInfo.cs b/Src/Support/Google.Apis.Auth/AssemblyInfo.cs index bb42f4333f..5f162f06ce 100644 --- a/Src/Support/Google.Apis.Auth/AssemblyInfo.cs +++ b/Src/Support/Google.Apis.Auth/AssemblyInfo.cs @@ -17,3 +17,4 @@ limitations under the License. using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Google.Apis.Auth.Tests,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] +[assembly: InternalsVisibleTo("IntegrationTests,PublicKey=00240000048000009400000006020000002400005253413100040000010001003d69fa08add2ea7341cc102edb2f3a59bb49e7f7c8bf1bd96d494013c194f4d80ee30278f20e08a0b7cb863d6522d8c1c0071dd36748297deefeb99e899e6a80b9ddc490e88ea566d2f7d4f442211f7beb6b2387fb435bfaa3ecfe7afc0184cc46f80a5866e6bb8eb73f64a3964ed82f6a5036b91b1ac93e1f44508b65e51fce")] diff --git a/Src/Support/Google.Apis.Auth/Google.Apis.Auth.csproj b/Src/Support/Google.Apis.Auth/Google.Apis.Auth.csproj index 64db9fd054..03ba55cbe7 100644 --- a/Src/Support/Google.Apis.Auth/Google.Apis.Auth.csproj +++ b/Src/Support/Google.Apis.Auth/Google.Apis.Auth.csproj @@ -18,7 +18,7 @@ This package includes auth components like user-credential, authorization code f Supported Platforms: - .NET Framework 4.5+ -- NetStandard1.3 +- NetStandard1.3, providing .NET Core support diff --git a/Src/Support/Google.Apis.Auth/GoogleJsonWebSignature.cs b/Src/Support/Google.Apis.Auth/GoogleJsonWebSignature.cs index bff3ada397..41a47d027d 100644 --- a/Src/Support/Google.Apis.Auth/GoogleJsonWebSignature.cs +++ b/Src/Support/Google.Apis.Auth/GoogleJsonWebSignature.cs @@ -14,6 +14,19 @@ You may obtain a copy of the License at limitations under the License. */ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Json; +using Google.Apis.Util; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + namespace Google.Apis.Auth { /// @@ -21,8 +34,176 @@ namespace Google.Apis.Auth /// public class GoogleJsonWebSignature { - // TODO(peleyal): We should provide a way to verify JWS. - // Take a look at: https://github.com/googleplus/gplus-verifytoken-csharp/blob/master/verifytoken.ashx.cs. + + internal const int MaxJwtLength = 10000; + internal readonly static TimeSpan CertCacheRefreshInterval = TimeSpan.FromHours(1); + + // See http://oid-info.com/get/2.16.840.1.101.3.4.2.1 + private const string Sha256Oid = "2.16.840.1.101.3.4.2.1"; + + private const string SupportedJwtAlgorithm = "RS256"; + + private static readonly IEnumerable ValidJwtIssuers = new[] + { + "https://accounts.google.com", + "accounts.google.com" + }; + + private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Validates a Google-issued Json Web Token (JWT). + /// With throw a if the passed value is not valid JWT signed by Google. + /// + /// + /// Follows the procedure to + /// validate a JWT ID token. + /// + /// Google certificates are cached, and refreshed once per hour. This can be overridden by setting + /// to true. + /// + /// The JWT to validate. + /// Optional. The to use for JWT expiration verification. Defaults to the system clock. + /// Optional. If true forces new certificates to be downloaded from Google. Defaults to false. + /// The JWT payload, if the JWT is valid. Throws an otherwise. + /// Thrown when passed a JWT that is not a valid JWT signed by Google. + public static Task ValidateAsync(string jwt, IClock clock = null, bool forceGoogleCertRefresh = false) => + ValidateInternalAsync(jwt, clock ?? SystemClock.Default, forceGoogleCertRefresh, null); + + // internal for testing + internal static async Task ValidateInternalAsync(string jwt, IClock clock, bool forceGoogleCertRefresh, string certsJson) + { + // Check arguments + jwt.ThrowIfNull(nameof(jwt)); + jwt.ThrowIfNullOrEmpty(nameof(jwt)); + if (jwt.Length > MaxJwtLength) + { + throw new InvalidJwtException($"JWT exceeds maximum allowed length of {MaxJwtLength}"); + } + var parts = jwt.Split('.'); + if (parts.Length != 3) + { + throw new InvalidJwtException($"JWT must consist of Header, Payload, and Signature"); + } + + // Decode the three parts of the JWT: header.payload.signature + Header header = NewtonsoftJsonSerializer.Instance.Deserialize
(Base64UrlToString(parts[0])); + Payload payload = NewtonsoftJsonSerializer.Instance.Deserialize(Base64UrlToString(parts[1])); + byte[] signature = Base64UrlDecode(parts[2]); + + // Verify algorithm in JWT + if (header.Algorithm != SupportedJwtAlgorithm) + { + throw new InvalidJwtException($"JWT algorithm must be '{SupportedJwtAlgorithm}'"); + } + + // Verify signature + byte[] hash; + using (var hashAlg = SHA256.Create()) + { + hash = hashAlg.ComputeHash(Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}")); + } + bool verifiedOk = false; + foreach (var googleCert in await GetGoogleCertsAsync(clock, forceGoogleCertRefresh, certsJson)) + { +#if NET45 + verifiedOk = ((RSACryptoServiceProvider)googleCert).VerifyHash(hash, Sha256Oid, signature); +#elif NETSTANDARD1_3 + verifiedOk = googleCert.VerifyHash(hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); +#else +#error Unsupported platform +#endif + if (verifiedOk) + { + break; + } + } + if (!verifiedOk) + { + throw new InvalidJwtException("JWT invalid: unable to verify signature."); + } + + // Verify iss, iat and exp claims + if (!ValidJwtIssuers.Contains(payload.Issuer)) + { + var validList = string.Join(", ", ValidJwtIssuers.Select(x => $"'{x}'")); + throw new InvalidJwtException($"JWT issuer incorrect. Must be one of: {validList}"); + } + if (payload.IssuedAtTimeSeconds == null || payload.ExpirationTimeSeconds == null) + { + throw new InvalidJwtException("JWT must contain 'iat' and 'exp' claims"); + } + var nowSeconds = (clock.UtcNow - UnixEpoch).TotalSeconds; + if (nowSeconds < payload.IssuedAtTimeSeconds.Value) + { + throw new InvalidJwtException("JWT is not yet valid."); + } + if (nowSeconds > payload.ExpirationTimeSeconds.Value) + { + throw new InvalidJwtException("JWT has expired."); + } + + // All verification passed, return payload. + return payload; + } + + private static string Base64UrlToString(string base64Url) => Encoding.UTF8.GetString(Base64UrlDecode(base64Url)); + + private static byte[] Base64UrlDecode(string base64Url) + { + var base64 = base64Url.Replace('-', '+').Replace('_', '/'); + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + return Convert.FromBase64String(base64); + } + + private static SemaphoreSlim _certCacheLock = new SemaphoreSlim(1); + private static DateTime _certCacheDownloadTime; + private static List _certCache; + + // internal for testing + internal static async Task> GetGoogleCertsAsync(IClock clock, bool forceGoogleCertRefresh, string certsJson) + { + var now = clock.UtcNow; + await _certCacheLock.WaitAsync(); + try + { + if (forceGoogleCertRefresh || _certCache == null || (_certCacheDownloadTime + CertCacheRefreshInterval) < now) + { + using (var httpClient = new HttpClient()) + { + // certsJson used for unit tests + if (certsJson == null) + { + certsJson = await httpClient.GetStringAsync(GoogleAuthConsts.JsonWebKeySetUrl); + } + } + _certCache = GetGoogleCertsFromJson(certsJson); + _certCacheDownloadTime = now; + } + return _certCache; + } + finally + { + _certCacheLock.Release(); + } + } + + private static List GetGoogleCertsFromJson(string json) => + JToken.Parse(json)["keys"].AsEnumerable().Select(key => + { + var rsa = RSA.Create(); + rsa.ImportParameters(new RSAParameters + { + Modulus = Base64UrlDecode((string)key["n"]), + Exponent = Base64UrlDecode((string)key["e"]), + }); + return rsa; + }) + .ToList(); /// /// The header as specified in https://developers.google.com/accounts/docs/OAuth2ServiceAccount#formingheader. diff --git a/Src/Support/Google.Apis.Auth/InvalidJwtException.cs b/Src/Support/Google.Apis.Auth/InvalidJwtException.cs new file mode 100644 index 0000000000..ef5879c209 --- /dev/null +++ b/Src/Support/Google.Apis.Auth/InvalidJwtException.cs @@ -0,0 +1,32 @@ +/* +Copyright 2017 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using System; + +namespace Google.Apis.Auth +{ + /// + /// An exception that is thrown when a Json Web Token (JWT) is invalid. + /// + public class InvalidJwtException : Exception + { + /// + /// Initializes a new InvalidJwtException instanc e with the specified error message. + /// + /// The error message that explains why the JWT was invalid. + public InvalidJwtException(string message) : base(message) { } + } +} diff --git a/Src/Support/Google.Apis.Auth/OAuth2/GoogleAuthConsts.cs b/Src/Support/Google.Apis.Auth/OAuth2/GoogleAuthConsts.cs index 408599b86a..b62116684c 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/GoogleAuthConsts.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/GoogleAuthConsts.cs @@ -54,6 +54,9 @@ public static class GoogleAuthConsts /// The path to the Google revocation endpoint. public const string RevokeTokenUrl = "https://accounts.google.com/o/oauth2/revoke"; + /// The OpenID Connect Json Web Key Set (jwks) URL. + public const string JsonWebKeySetUrl = "https://www.googleapis.com/oauth2/v3/certs"; + /// Installed application redirect URI. public const string InstalledAppRedirectUri = "urn:ietf:wg:oauth:2.0:oob"; diff --git a/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs index 9615a6f81e..41a2c4d14a 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs @@ -81,11 +81,21 @@ internal GoogleCredential(ICredential credential) /// /// /// + /// A task which completes with the application default credentials. public static Task GetApplicationDefaultAsync() { return defaultCredentialProvider.GetDefaultCredentialAsync(); } + /// + /// Synchronously returns the Application Default Credentials which are ambient credentials that identify and authorize + /// the whole application. See for details on application default credentials. + /// This method will block until the credentials are available (or an exception is thrown). + /// It is highly preferable to call where possible. + /// + /// The application default credentials. + public static GoogleCredential GetApplicationDefault() => Task.Run(() => GetApplicationDefaultAsync()).Result; + /// /// Loads credential from stream containing JSON credential data. /// diff --git a/Src/Support/Google.Apis.Auth/OAuth2/LocalServerCodeReceiver.cs b/Src/Support/Google.Apis.Auth/OAuth2/LocalServerCodeReceiver.cs index 22c5b2155c..4b7026c883 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/LocalServerCodeReceiver.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/LocalServerCodeReceiver.cs @@ -379,22 +379,23 @@ private HttpListener StartListener() private async Task GetResponseFromListener(HttpListener listener, CancellationToken ct) { + HttpListenerContext context; // Set up cancellation. HttpListener.GetContextAsync() doesn't accept a cancellation token, // the HttpListener needs to be stopped which immediately aborts the GetContextAsync() call. - ct.Register(listener.Stop); - - // Wait to get the authorization code response. - HttpListenerContext context; - try - { - context = await listener.GetContextAsync().ConfigureAwait(false); - } - catch (HttpListenerException) when (ct.IsCancellationRequested) + using (ct.Register(listener.Stop)) { - ct.ThrowIfCancellationRequested(); - // Next line will never be reached because cancellation will always have been requested in this catch block. - // But it's required to satisfy compiler. - throw new InvalidOperationException(); + // Wait to get the authorization code response. + try + { + context = await listener.GetContextAsync().ConfigureAwait(false); + } + catch (Exception) when (ct.IsCancellationRequested) + { + ct.ThrowIfCancellationRequested(); + // Next line will never be reached because cancellation will always have been requested in this catch block. + // But it's required to satisfy compiler. + throw new InvalidOperationException(); + } } NameValueCollection coll = context.Request.QueryString; diff --git a/Src/Support/Google.Apis.Core/Google.Apis.Core.csproj b/Src/Support/Google.Apis.Core/Google.Apis.Core.csproj index e4ed865f8c..1f1921312a 100644 --- a/Src/Support/Google.Apis.Core/Google.Apis.Core.csproj +++ b/Src/Support/Google.Apis.Core/Google.Apis.Core.csproj @@ -16,7 +16,7 @@ The Google APIs Core Library contains the Google APIs HTTP layer, JSON support, Supported Platforms: - .NET Framework 4.5+ -- NetStandard1.3 +- NetStandard1.3, providing .NET Core support diff --git a/Src/Support/Google.Apis.Tests/Apis/Services/BaseClientServiceTest.cs b/Src/Support/Google.Apis.Tests/Apis/Services/BaseClientServiceTest.cs index 846db156e7..a127bc7959 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Services/BaseClientServiceTest.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Services/BaseClientServiceTest.cs @@ -325,5 +325,38 @@ public void TestGetWithUrlMaxLengthDisabled() Assert.Equal(new Uri(requestUri), request.RequestUri); } } + + [Fact] + public void ValidApplicationName() + { + // Assert no exception thrown + new MockClientService(new BaseClientService.Initializer + { + ApplicationName = "AppName" + }); + } + + [Fact] + public void NullApplicationName() + { + // Assert no exception thrown + new MockClientService(new BaseClientService.Initializer + { + ApplicationName = null + }); + } + + [Fact] + public void InvalidApplicationName() + { + Assert.Throws(() => + { + new MockClientService(new BaseClientService.Initializer + { + ApplicationName = "AppName = a name" + }); + }); + } + } } diff --git a/Src/Support/Google.Apis/Google.Apis.csproj b/Src/Support/Google.Apis/Google.Apis.csproj index 6c8bdb0743..983fad0477 100644 --- a/Src/Support/Google.Apis/Google.Apis.csproj +++ b/Src/Support/Google.Apis/Google.Apis.csproj @@ -17,7 +17,7 @@ The library supports service requests, media upload and download, etc. Supported Platforms: - .NET Framework 4.5+ -- NetStandard1.3 +- NetStandard1.3, providing .NET Core support diff --git a/Src/Support/Google.Apis/Services/BaseClientService.cs b/Src/Support/Google.Apis/Services/BaseClientService.cs index 54345fdb26..0ec4a3cf35 100644 --- a/Src/Support/Google.Apis/Services/BaseClientService.cs +++ b/Src/Support/Google.Apis/Services/BaseClientService.cs @@ -114,6 +114,14 @@ public Initializer() DefaultExponentialBackOffPolicy = ExponentialBackOffPolicy.UnsuccessfulResponse503; MaxUrlLength = DefaultMaxUrlLength; } + + internal void Validate() + { + if (ApplicationName != null && !ProductInfoHeaderValue.TryParse(ApplicationName, out var _)) + { + throw new ArgumentException("Invalid Application name", nameof(ApplicationName)); + } + } } #endregion @@ -121,6 +129,7 @@ public Initializer() /// Constructs a new base client with the specified initializer. protected BaseClientService(Initializer initializer) { + initializer.Validate(); // Set the right properties by the initializer's properties. GZipEnabled = initializer.GZipEnabled; Serializer = initializer.Serializer; diff --git a/Src/Support/IntegrationTests/AuthUriConstsTests.cs b/Src/Support/IntegrationTests/AuthUriConstsTests.cs index 714c38ab51..bf91302ed3 100644 --- a/Src/Support/IntegrationTests/AuthUriConstsTests.cs +++ b/Src/Support/IntegrationTests/AuthUriConstsTests.cs @@ -41,6 +41,7 @@ public async Task VerifyGoogleAuthConsts() Assert.Equal(wellKnown["authorization_endpoint"].ToString(), GoogleAuthConsts.OidcAuthorizationUrl); Assert.Equal(wellKnown["token_endpoint"].ToString(), GoogleAuthConsts.OidcTokenUrl); Assert.Equal(wellKnown["revocation_endpoint"].ToString(), GoogleAuthConsts.RevokeTokenUrl); + Assert.Equal(wellKnown["jwks_uri"].ToString(), GoogleAuthConsts.JsonWebKeySetUrl); } } } diff --git a/Src/Support/IntegrationTests/GoogleJsonWebSignatureTests.cs b/Src/Support/IntegrationTests/GoogleJsonWebSignatureTests.cs new file mode 100644 index 0000000000..b8cffa35d1 --- /dev/null +++ b/Src/Support/IntegrationTests/GoogleJsonWebSignatureTests.cs @@ -0,0 +1,87 @@ +/* +Copyright 2017 Google Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Auth; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2.Flows; +using Google.Apis.Auth.OAuth2.Requests; +using Google.Apis.Util; +using Google.Apis.Util.Store; +using Newtonsoft.Json.Linq; +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace IntegrationTests +{ + public class GoogleJsonWebSignatureTests + { + [Fact] + public async Task GetGoogleCerts() + { + // Verifies certs are downloaded and loaded into RSAs + var certs = await GoogleJsonWebSignature.GetGoogleCertsAsync(SystemClock.Default, false, null); + Assert.NotEmpty(certs); + } + + [Fact] + public async Task GetAndValidateJwt() + { + // Warning: This test is interactive! + // It will bring up a browser window that must be responded to before the test can complete. + + // Do auth. + var codeReceiver = new LocalServerCodeReceiver(); + var initializer = new GoogleAuthorizationCodeFlow.Initializer + { + ClientSecretsStream = Helper.GetClientSecretStream(), + Scopes = new string[] { "openid email" }, + }; + var flow = new GoogleAuthorizationCodeFlow(initializer); + var redirectUri = codeReceiver.RedirectUri; + AuthorizationCodeRequestUrl codeRequest = flow.CreateAuthorizationCodeRequest(redirectUri); + + // Receive the code. + var response = await codeReceiver.ReceiveCodeAsync(codeRequest, CancellationToken.None); + var code = response.Code; + + // Get a JWT from code. + var secretJson = JToken.Parse(Helper.GetClientSecret()); + var codeReq = "https://www.googleapis.com/oauth2/v4/token"; + var contentStr = "code=" + code + + "&client_id=" + secretJson["installed"]["client_id"] + + "&client_secret=" + secretJson["installed"]["client_secret"] + + "&redirect_uri=" + redirectUri + + "&grant_type=authorization_code"; + var contentBytes = Encoding.ASCII.GetBytes(contentStr); + var content = new ByteArrayContent(contentBytes); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"); + var httpClient = new HttpClient(); + var res = httpClient.PostAsync(codeReq, content).Result; + var json = JToken.Parse(Encoding.UTF8.GetString(await res.Content.ReadAsByteArrayAsync())); + var jwt = (string)json["id_token"]; + + // Confirm JWT is valid + var validPayload = await GoogleJsonWebSignature.ValidateAsync(jwt); + Assert.NotNull(validPayload); + } + } +} diff --git a/Src/Support/IntegrationTests/IntegrationTests.Utils/Helper.cs b/Src/Support/IntegrationTests/IntegrationTests.Utils/Helper.cs index 32cf54bb91..7297c0227c 100644 --- a/Src/Support/IntegrationTests/IntegrationTests.Utils/Helper.cs +++ b/Src/Support/IntegrationTests/IntegrationTests.Utils/Helper.cs @@ -17,6 +17,7 @@ limitations under the License. using Google.Apis.Auth.OAuth2; using System; using System.IO; +using System.Text; namespace IntegrationTests { @@ -63,6 +64,8 @@ internal static class Helper public static GoogleCredential GetServiceCredential() => s_serviceCredential.Value; + public static string GetClientSecret() => Encoding.UTF8.GetString(s_clientSecret.Value); + public static MemoryStream GetClientSecretStream() => new MemoryStream(s_clientSecret.Value); } }