sources/Google.Solutions.Apis/Auth/Gaia/GaiaOidcClient.cs (213 lines of code) (raw):
//
// Copyright 2023 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.Auth.OAuth2.Flows;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Solutions.Apis.Client;
using Google.Solutions.Common.Diagnostics;
using Google.Solutions.Common.Util;
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Google.Solutions.Apis.Auth.Gaia
{
/// <summary>
/// Client for Google "1PI" OIDC.
/// </summary>
public class GaiaOidcClient : OidcClientBase
{
private readonly ServiceEndpoint<GaiaOidcClient> endpoint;
private readonly IDeviceEnrollment deviceEnrollment;
private readonly UserAgent userAgent;
public GaiaOidcClient(
ServiceEndpoint<GaiaOidcClient> endpoint,
IDeviceEnrollment deviceEnrollment,
IOidcOfflineCredentialStore store,
OidcClientRegistration registration,
UserAgent userAgent)
: base(store, registration)
{
Precondition.Expect(registration.Issuer == OidcIssuer.Gaia, nameof(OidcIssuer));
this.endpoint = endpoint.ExpectNotNull(nameof(endpoint));
this.deviceEnrollment = deviceEnrollment.ExpectNotNull(nameof(deviceEnrollment));
this.userAgent = userAgent.ExpectNotNull(nameof(userAgent));
Precondition.Expect(registration.Issuer == OidcIssuer.Gaia, "Issuer");
Precondition.Expect(registration.RedirectPath == "/authorize/", "RedirectPath");
}
public static ServiceEndpoint<GaiaOidcClient> CreateEndpoint(
ServiceRoute? route = null)
{
return new ServiceEndpoint<GaiaOidcClient>(
route ?? ServiceRoute.Public,
"https://oauth2.googleapis.com/");
}
//---------------------------------------------------------------------
// IClient.
//---------------------------------------------------------------------
public override IServiceEndpoint Endpoint => this.endpoint;
//---------------------------------------------------------------------
// Helper methods.
//---------------------------------------------------------------------
protected virtual IAuthorizationCodeFlow CreateFlow(
GoogleAuthorizationCodeFlow.Initializer initializer)
{
return new GoogleAuthorizationCodeFlow(initializer);
}
internal static GaiaOidcSession CreateSession(
IAuthorizationCodeFlow flow,
OidcOfflineCredential? offlineCredential,
TokenResponse tokenResponse)
{
flow.ExpectNotNull(nameof(flow));
tokenResponse.ExpectNotNull(nameof(tokenResponse));
Debug.Assert(tokenResponse.RefreshToken != null);
Debug.Assert(tokenResponse.AccessToken != null);
if (tokenResponse.IdToken != null)
{
//
// We got a fresh ID token because the original
// authorization included the email scope.
//
Debug.Assert(tokenResponse.Scope
.Split(' ')
.Contains(Scopes.Email));
var apiCredential = new UserCredential(flow, null, tokenResponse);
var idToken = tokenResponse.IdToken;
//
// Use the fresh ID token.
//
// NB. We but don't verify the ID token here because
// verification requires access to the JWKS, and the JWKS
// might not be available over PSC.
//
return new GaiaOidcSession(
apiCredential,
UnverifiedGaiaJsonWebToken.Decode(idToken));
}
else if (offlineCredential != null &&
!string.IsNullOrEmpty(offlineCredential.IdToken) &&
UnverifiedGaiaJsonWebToken.Decode(offlineCredential.IdToken!) is var offlineIdToken &&
offlineIdToken != null &&
!string.IsNullOrEmpty(offlineIdToken.Payload.Email))
{
//
// We didn't get a new ID token, but we still have
// the one from last time. This one might be expired,
// but that doesn't matter since we only use it to
// extract the email address.
//
Debug.Assert(!tokenResponse.Scope
.Split(' ')
.Contains(Scopes.Email));
var apiCredential = new UserCredential(flow, null, tokenResponse);
return new GaiaOidcSession(
apiCredential,
offlineIdToken);
}
else
{
//
// We don't have any usable ID token.
//
throw new OAuthScopeNotGrantedException(
"The offline credential neither contains an existing ID token " +
"nor the necessary scopes to obtain an ID token");
}
}
private GaiaOidcSession CreateSessionAndRegisterTerminateEvent(
IAuthorizationCodeFlow flow,
OidcOfflineCredential? offlineCredential,
TokenResponse tokenResponse)
{
var session = CreateSession(
flow,
offlineCredential,
tokenResponse);
Debug.Assert(session.IdToken.Payload.Email != null);
session.Terminated += (_, __) => ClearOfflineCredentialStore();
return session;
}
//---------------------------------------------------------------------
// Overrides.
//---------------------------------------------------------------------
protected override async Task<IOidcSession> AuthorizeWithBrowserAsync(
OidcOfflineCredential? offlineCredential,
ICodeReceiver codeReceiver,
CancellationToken cancellationToken)
{
Precondition.Expect(offlineCredential == null ||
offlineCredential.Issuer == OidcIssuer.Gaia,
"Offline credential must be issued by Gaia");
codeReceiver.ExpectNotNull(nameof(codeReceiver));
var initializer = new GaiaCodeFlow.Initializer(
this.endpoint,
this.deviceEnrollment,
this.userAgent)
{
ClientSecrets = this.Registration.ToClientSecrets()
};
if (offlineCredential?.IdToken != null &&
UnverifiedGaiaJsonWebToken.TryDecode(offlineCredential.IdToken, out var offlineIdToken) &&
offlineIdToken != null &&
!string.IsNullOrEmpty(offlineIdToken.Payload.Email))
{
//
// We still have an ID token with an email address, so we can perform
// a "minimal flow":
//
// - use existing email as login hint (to skip account chooser)
// - don't request the email scope again so that consent unbundling
// doesn't apply
//
// NB. The last point is important and the entire point why we're storing
// the ID token: Consent unbundling (i.e., the behavior of the OAuth consent
// screen where it shows unchecked checkboxes for all scopes) only applies
// when we request two or more scopes. By only requesting a single scope,
// we can sidestep consent unbundling, thereby improving UX.
//
initializer.LoginHint = offlineIdToken.Payload.Email;
initializer.Scopes = new[] { Scopes.Cloud };
}
else
{
initializer.Scopes = new[] { Scopes.Cloud, Scopes.Email };
}
try
{
var flow = CreateFlow(initializer);
var app = new AuthorizationCodeInstalledApp(flow, codeReceiver);
var apiCredential = await
app.AuthorizeAsync(null, cancellationToken)
.ConfigureAwait(true);
//
// Verify that all requested scopes have been granted.
//
var grantedScopes = apiCredential.Token.Scope?.Split(' ');
if (initializer.Scopes.Any(
requestedScope => !grantedScopes.Contains(requestedScope)))
{
throw new OAuthScopeNotGrantedException(
"Authorization failed because you have denied access to a " +
"required resource. Sign in again and make sure " +
"to grant access to all requested resources.");
}
try
{
//
// N.B. Do not dispose the flow if the sign-in succeeds as the
// credential object must hold on to it.
//
return CreateSessionAndRegisterTerminateEvent(
flow,
offlineCredential,
apiCredential.Token);
}
catch
{
flow.Dispose();
throw;
}
}
catch (TokenResponseException e) when (
e.Error?.ErrorUri != null &&
e.Error.ErrorUri.StartsWith("https://accounts.google.com/info/servicerestricted"))
{
if (this.deviceEnrollment.State == DeviceEnrollmentState.Enrolled)
{
throw new AuthorizationFailedException(
"Authorization failed because your computer's device certificate is " +
"is invalid or unrecognized. Use the Endpoint Verification extension " +
"to verify that your computer is enrolled and try again.\n\n" +
e.Error.ErrorDescription);
}
else
{
throw new AuthorizationFailedException(
"Authorization failed because a context-aware access requirement " +
"was not met.\n\n" + e.Error.ErrorDescription);
}
}
}
protected override async Task<IOidcSession> ActivateOfflineCredentialAsync(
OidcOfflineCredential offlineCredential,
CancellationToken cancellationToken)
{
offlineCredential.ExpectNotNull(nameof(offlineCredential));
Precondition.Expect(offlineCredential.Issuer == OidcIssuer.Gaia,
"Offline credential must be issued by Gaia");
var initializer = new GaiaCodeFlow.Initializer(
this.endpoint,
this.deviceEnrollment,
this.userAgent)
{
ClientSecrets = this.Registration.ToClientSecrets()
};
var flow = CreateFlow(initializer);
//
// Try to use the refresh token to obtain a new access token.
//
try
{
var tokenResponse = await flow
.RefreshTokenAsync(null, offlineCredential.RefreshToken, cancellationToken)
.ConfigureAwait(false);
//
// N.B. Do not dispose the flow if the sign-in succeeds as the
// credential object must hold on to it.
//
return CreateSessionAndRegisterTerminateEvent(
flow,
offlineCredential,
tokenResponse);
}
catch (Exception e)
{
ApiTraceSource.Log.TraceWarning(
"Refreshing the stored token failed: {0}", e.FullMessage());
//
// The refresh token must have been revoked or
// the session expired (reauth).
//
flow.Dispose();
throw;
}
}
}
}