File size: 7,473 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
/*
Copyright 2018 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

    https://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 Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace Google.Apis.Auth.AspNetCore3;

internal class GoogleAuthProvider : IGoogleAuthProvider
{
    private static TimeSpan s_accessTokenRefreshWindow = TimeSpan.FromMinutes(5);

    public GoogleAuthProvider(IHttpContextAccessor httpContextAccessor,
        GoogleAuthenticationSchemeProvider schemeProvider, ISystemClock clock, IOptionsMonitor<OpenIdConnectOptions> options)
    {
        _httpContextAccessor = httpContextAccessor;
        _scheme = schemeProvider.Scheme;
        _clock = clock;
        _options = options;
    }

    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly string _scheme;
    private readonly ISystemClock _clock;
    private readonly IOptionsMonitor<OpenIdConnectOptions> _options;

    public async Task<GoogleCredential> GetCredentialAsync(
        TimeSpan? accessTokenRefreshWindow = null, CancellationToken cancellationToken = default(CancellationToken))
    {
        var httpContext = _httpContextAccessor.HttpContext;
        var auth = await httpContext.AuthenticateAsync(_scheme);
        if (!auth.Succeeded || auth.None)
        {
            throw new InvalidOperationException("Cannot get credential when not authenticated.");
        }
        var accessToken = auth.Properties.GetTokenValue(OpenIdConnectParameterNames.AccessToken);
        var refreshToken = auth.Properties.GetTokenValue(OpenIdConnectParameterNames.RefreshToken);
        // Get expiration of Google auth-token. The "expires_at" name and "o" format are hard-coded into:
        // https://github.com/aspnet/AspNetCore/blob/562d119ca4a4275359f6fae359120a2459cd39e9/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs#L940
        // Do not use `auth.Properties.ExpiresUtc`, as this is the cookie expiration time, not the Google IdToken expiration time.
        var expiresUtcStr = auth.Properties.GetTokenValue("expires_at");
        if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken) || expiresUtcStr == null ||
            !DateTime.TryParseExact(expiresUtcStr, "o", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var expiresUtc))
        {
            throw new InvalidOperationException("Invalid auth. access_token, refresh_token, and expires_at must all be present.");
        }
        var now = _clock.UtcNow;
        if (expiresUtc - (accessTokenRefreshWindow ?? s_accessTokenRefreshWindow) < now)
        {
            // Refresh required. This has to be done inline here (it can't be done in the background)
            // because the request auth properties need to be updated with the result.
            var options = _options.Get(_scheme);
            var oidcConfig = await options.ConfigurationManager.GetConfigurationAsync(cancellationToken);
            var refreshContent = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                { "client_id", options.ClientId },
                { "client_secret", options.ClientSecret },
                { "grant_type", "refresh_token" },
                { "refresh_token", auth.Properties.GetTokenValue("refresh_token") }
            });
            try
            {
                var refreshResponse = await options.Backchannel.PostAsync(oidcConfig.TokenEndpoint, refreshContent, cancellationToken);
                refreshResponse.EnsureSuccessStatusCode();
                var payload = JObject.Parse(await refreshResponse.Content.ReadAsStringAsync());
                var refreshedAccessToken = payload.Value<string>("access_token");
                var refreshedRefreshToken = payload.Value<string>("refresh_token");
                var refreshedExpiresIn = payload.Value<string>("expires_in");
                var refreshedIdToken = payload.Value<string>("id_token");
                auth.Properties.UpdateTokenValue(OpenIdConnectParameterNames.AccessToken, refreshedAccessToken);
                if (!string.IsNullOrEmpty(refreshedRefreshToken))
                {
                    auth.Properties.UpdateTokenValue(OpenIdConnectParameterNames.RefreshToken, refreshedRefreshToken);
                }
                if (int.TryParse(refreshedExpiresIn, out int expiresInSeconds))
                {
                    var refreshedExpiresAt = now.AddSeconds(expiresInSeconds);
                    auth.Properties.UpdateTokenValue("expires_at", refreshedExpiresAt.ToString("o"));
                }
                if (!string.IsNullOrEmpty(refreshedIdToken))
                {
                    auth.Properties.UpdateTokenValue(OpenIdConnectParameterNames.IdToken, refreshedIdToken);
                }
                accessToken = refreshedAccessToken;
            }
            catch (Exception e)
            {
                throw new InvalidOperationException("Failed to refresh access_token.", e);
            }
            // Sign-in, to store the refreshed auth tokens (often stored into a cookie).
            await httpContext.SignInAsync(options.SignInScheme, auth.Principal, auth.Properties);
        }
        // Return a short-term, non-refreshable credential.
        return GoogleCredential.FromAccessToken(accessToken);
    }

    public async Task<IReadOnlyList<string>> GetCurrentScopesAsync()
    {
        var httpContext = _httpContextAccessor.HttpContext;
        var auth = await httpContext.AuthenticateAsync(_scheme);
        if (!auth.Succeeded || auth.None)
        {
            throw new InvalidOperationException("Cannot get scopes when not authenticated.");
        }
        auth.Properties.Items.TryGetValue(Consts.ScopeName, out var scope);
        return (scope ?? "").Split(Consts.ScopeSplitter, StringSplitOptions.RemoveEmptyEntries);
    }

    public async Task<IActionResult> RequireScopesAsync(params string[] scopes)
    {
        var currentScopes = await GetCurrentScopesAsync();
        var additionalScopes = scopes.Except(currentScopes).ToList();
        if (additionalScopes.Any())
        {
            // Store the additional scopes required in the HttpContext.
            _httpContextAccessor.HttpContext.Items[Consts.HttpContextAdditionalScopeName] = string.Join(" ", additionalScopes);
            // Return forbid, we check on forbid for the additional scopes and challenge if needed.
            return new ForbidResult(_scheme);
        }
        else
        {
            // All scopes already authorized.
            return null;
        }
    }
}