File size: 13,667 Bytes
7b715bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
/*
Copyright 2020 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.OAuth2;
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.Threading;
using System.Threading.Tasks;
using static Google.Apis.Auth.JsonWebSignature;

namespace Google.Apis.Auth
{
    internal static class SignedTokenVerification
    {
#if NET462_OR_GREATER
        // In .NET 4.6.2 we don't have the handy ECParameters struct so we need to pass the X and Y
        // in the expected format. (It was introduced in .NET 4.7.)
        // See here for the format of the key: https://docs.microsoft.com/en-us/windows/win32/api/bcrypt/ns-bcrypt-bcrypt_ecckey_blob
        // And here for the correct prefix for ECDsa: https://stackoverflow.com/a/44527439/1122643
        private static readonly byte[] s_cngBlobPrefix = { 0x45, 0x43, 0x53, 0x31, 0x20, 0, 0, 0 };
#endif

        private static readonly CertificateCache s_certificateCache = new CertificateCache();

        internal async static Task VerifySignedTokenAsync<TJswHeader, TJswPayload>(
            SignedToken<TJswHeader, TJswPayload> signedToken, SignedTokenVerificationOptions options, CancellationToken cancellationToken)
            where TJswHeader : Header
            where TJswPayload : Payload
        {
            signedToken.ThrowIfNull(nameof(signedToken));
            options = options == null ? new SignedTokenVerificationOptions() : new SignedTokenVerificationOptions(options);
            options.CertificateCache ??= s_certificateCache;

            // Start the signature validation task...
            Task signatureVerificationTask = signedToken.Header.Algorithm switch
            {
                "RS256" => VerifyRS256TokenAsync(signedToken, options, cancellationToken),
                "ES256" => VerifyES256TokenAsync(signedToken, options, cancellationToken),
                _ => throw new InvalidJwtException("Signing algorithm must be either RS256 or ES256.")
            };

            // ... let's validate everything else while we wait for signature validation...

            // The signed token issuer should be one of the trusted issuers.
            if (options.TrustedIssuers.Count > 0 && !options.TrustedIssuers.Contains(signedToken.Payload.Issuer))
            {
                var validList = string.Join(", ", options.TrustedIssuers.Select(x => $"'{x}'"));
                throw new InvalidJwtException($"JWT issuer incorrect. Must be one of: {validList}");
            }
            // All audiences on the signed token should be trusted audiences.
            if (options.TrustedAudiences.Count > 0 && signedToken.Payload.AudienceAsList.Except(options.TrustedAudiences).Any())
            {
                throw new InvalidJwtException("JWT contains untrusted 'aud' claim.");
            }
            // Issued at and expiration are present. Save them for time related validity checks.
            DateTimeOffset issuedAt = signedToken.Payload.IssuedAt ?? throw new InvalidJwtException("JWT must contain 'iat' and 'exp' claims");
            DateTimeOffset expiresAt = signedToken.Payload.ExpiresAt ?? throw new InvalidJwtException("JWT must contain 'iat' and 'exp' claims");

            // Check that the token was issued in the past.
            var utcNow = options.Clock.UtcNow;
            if (utcNow + options.IssuedAtClockTolerance < issuedAt)
            {
                throw new InvalidJwtException("JWT is not yet valid.");
            }
            // Check that the token is not yet expired.
            if (utcNow - options.ExpiryClockTolerance > expiresAt)
            {
                throw new InvalidJwtException("JWT has expired.");
            }

            // ... and finally let's wait for signature validation to be done.
            await signatureVerificationTask.ConfigureAwait(false);
        }

        private async static Task VerifyRS256TokenAsync<TJswHeader, TJswPayload>(
            SignedToken<TJswHeader, TJswPayload> signedToken, SignedTokenVerificationOptions options, CancellationToken cancellationToken)
            where TJswHeader : Header
            where TJswPayload : Payload
        {
            var certificates = await GetCertificatesAsync(
                options, GoogleAuthConsts.JsonWebKeySetUrl, FromKeyToRsa, cancellationToken).ConfigureAwait(false);

            foreach (RSA certificate in certificates)
            {
                if (certificate.VerifyHash(signedToken.Sha256Hash, signedToken.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
                {
                    return;
                }
            }
            throw new InvalidJwtException("JWT invalid, unable to verify signature.");
        }

        // Internal only for testing purposes.
        internal static RSA FromKeyToRsa(JToken key)
        {
            var rsa = RSA.Create();
            rsa.ImportParameters(new RSAParameters
            {
                Modulus = TokenEncodingHelpers.Base64UrlDecode((string)key["n"]),
                Exponent = TokenEncodingHelpers.Base64UrlDecode((string)key["e"]),
            });
            return rsa;
        }

        private async static Task VerifyES256TokenAsync<TJswHeader, TJswPayload>(
            SignedToken<TJswHeader, TJswPayload> signedToken, SignedTokenVerificationOptions options, CancellationToken cancellationToken)
            where TJswHeader : Header
            where TJswPayload : Payload
        {
            var certificates = await GetCertificatesAsync(
                options, GoogleAuthConsts.IapKeySetUrl, FromKeyToECDsa, cancellationToken).ConfigureAwait(false);

            foreach (ECDsa certificate in certificates)
            {
                if (certificate.VerifyHash(signedToken.Sha256Hash, signedToken.Signature))
                {
                    return;
                }
            }
            throw new InvalidJwtException("JWT invalid, unable to verify signature.");

            static ECDsa FromKeyToECDsa(JToken key)
            {
                if ((string)key["kty"] != "EC" && (string)key["crv"] != "P-256")
                {
                    throw new ArgumentException(
                        $"For ES256 verification only certificates with kty='EC' and crv='P-256' are supported. Encountered: kty={(string)key["kty"]} and crv={(string)key["crv"]}.");
                }
                byte[] x = TokenEncodingHelpers.Base64UrlDecode((string)key["x"]);
                byte[] y = TokenEncodingHelpers.Base64UrlDecode((string)key["y"]);
                return BuildEcdsa(x, y);
            }
#if NETSTANDARD2_0 || NET6_0_OR_GREATER
            static ECDsa BuildEcdsa(byte[] x, byte[] y)
            {
                var ecdsa = ECDsa.Create();
                ecdsa.ImportParameters(new ECParameters
                {
                    // Curve used in ES256
                    Curve = ECCurve.NamedCurves.nistP256,
                    Q = new ECPoint { X = x, Y = y }
                });
                return ecdsa;
            }
#elif NET462
            static ECDsa BuildEcdsa(byte[] x, byte[] y)
            {
                // In .NET 4.6.2 we don't have the handy ECParameters class (introduced in .NET 4.7) so we need to pass the X and Y
                // in the expected format.
                // See here for the format of the key: https://docs.microsoft.com/en-us/windows/win32/api/bcrypt/ns-bcrypt-bcrypt_ecckey_blob
                // And here for the correct prefix for ECDsa: https://stackoverflow.com/a/44527439/1122643
                byte[] publicKey = new byte[s_cngBlobPrefix.Length + 64];
                Buffer.BlockCopy(s_cngBlobPrefix, 0, publicKey, 0, s_cngBlobPrefix.Length);
                Buffer.BlockCopy(x, 0, publicKey, s_cngBlobPrefix.Length, x.Length);
                Buffer.BlockCopy(y, 0, publicKey, s_cngBlobPrefix.Length + x.Length, y.Length);
                return new ECDsaCng(CngKey.Import(publicKey, CngKeyBlobFormat.EccPublicBlob));
            }
#endif
        }

        private static async Task<IEnumerable<AsymmetricAlgorithm>> GetCertificatesAsync(
            SignedTokenVerificationOptions options,
            string defaultCertificateLocation,
            Func<JToken, AsymmetricAlgorithm> certificateFactory,
            CancellationToken cancellationToken) =>
            await options.CertificateCache.GetCertificatesAsync(
                options.CertificatesUrl ?? defaultCertificateLocation,
                certificateFactory,
                options.ForceCertificateRefresh,
                cancellationToken).ConfigureAwait(false);

        // Abstract base class for testing purposes.
        internal abstract class CertificateCacheBase
        {
            internal static readonly TimeSpan DefaultCacheValidity = TimeSpan.FromHours(1);

            private readonly IDictionary<string, CachedCertificates> _cache = new Dictionary<string, CachedCertificates>();
            private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1);
            // For testing purposes only.
            // Note that this won't match the clock sent in for each verification request.
            // The cache is global and the clock is specified on a per verification request basis,
            // and changing that would be breaking.
            // Therefor, in production code, we always use the system clock for calculating certificate
            // caching expiry because we cannot get the certificates at a specific point in time,
            // but just at the real now.
            // That is, certificate validity is relative to real time and not to faked time.
            // This clock is just for use in unit testing and have a deterministic way of making the
            // cache invalid.
            private readonly IClock _clock;

            // For testing purposes.
            // See the note on CacheExpired to know why we don't have a clock here.
            protected CertificateCacheBase(IClock clock) =>
                _clock = clock ?? SystemClock.Default;

            public async Task<IEnumerable<AsymmetricAlgorithm>> GetCertificatesAsync(string certificatesLocation, Func<JToken, AsymmetricAlgorithm> certificateFactory, bool forceCertificateRefresh, CancellationToken cancellationToken)
            {
                certificatesLocation.ThrowIfNullOrEmpty(nameof(certificatesLocation));
                certificateFactory.ThrowIfNull(nameof(certificateFactory));

                await _cacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
                try
                {
                    if (forceCertificateRefresh ||
                        !_cache.TryGetValue(certificatesLocation, out CachedCertificates cachedCerts) ||
                        cachedCerts.Expired(_clock.UtcNow))
                    {
                        string certificatesJson = await FetchCertificatesAsync(certificatesLocation).ConfigureAwait(false);
                        IEnumerable<JToken> jwks = JToken.Parse(certificatesJson)["keys"]?.AsEnumerable() 
                            ?? throw new ArgumentException($"Only JWK formatted keys are currently supported. No 'keys' element was found in {certificatesLocation}");
                        if (!jwks.Any())
                        {
                            throw new ArgumentException($"No JWKs were found on {certificatesLocation}. The 'keys' element was empty.");
                        }
                        cachedCerts = new CachedCertificates(jwks.Select(key => certificateFactory(key)).ToList(), _clock.UtcNow);
                        _cache[certificatesLocation] = cachedCerts;
                    }
                    return cachedCerts.Certificates;
                }
                finally
                {
                    _cacheLock.Release();
                }
            }

            // Abstract for testing purposes.
            protected abstract Task<string> FetchCertificatesAsync(string certificatesLocation);

            internal struct CachedCertificates
            {
                private readonly DateTimeOffset _cachedSince;
                public IEnumerable<AsymmetricAlgorithm> Certificates { get; }

                public CachedCertificates(IEnumerable<AsymmetricAlgorithm> certificates, DateTimeOffset cachedSince)
                {
                    _cachedSince = cachedSince;
                    Certificates = certificates;
                }

                public bool Expired(DateTimeOffset now) => _cachedSince + DefaultCacheValidity <= now;
            }
        }

        internal sealed class CertificateCache : CertificateCacheBase
        {
            public CertificateCache() : base(SystemClock.Default)
            { }

            protected override async Task<string> FetchCertificatesAsync(string certificatesLocation)
            {
                using var httpClient = new HttpClient();
                return await httpClient.GetStringAsync(certificatesLocation).ConfigureAwait(false);
            }
        }
    }
}