sources/Google.Solutions.IapDesktop.Application/Windows/Auth/AuthorizeViewModel.cs (256 lines of code) (raw):
//
// Copyright 2022 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.Solutions.Apis.Auth;
using Google.Solutions.Apis.Auth.Gaia;
using Google.Solutions.Apis.Auth.Iam;
using Google.Solutions.Apis.Client;
using Google.Solutions.Common.Linq;
using Google.Solutions.Common.Util;
using Google.Solutions.IapDesktop.Application.Client;
using Google.Solutions.IapDesktop.Application.Diagnostics;
using Google.Solutions.IapDesktop.Application.Host;
using Google.Solutions.IapDesktop.Application.Profile.Auth;
using Google.Solutions.IapDesktop.Application.Profile.Settings;
using Google.Solutions.Mvvm.Binding;
using Google.Solutions.Mvvm.Binding.Commands;
using Google.Solutions.Mvvm.Controls;
using Google.Solutions.Platform.Net;
using Google.Solutions.Settings.Collection;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Google.Solutions.IapDesktop.Application.Windows.Auth
{
public class AuthorizeViewModel : ViewModelBase
{
private readonly ServiceEndpoint<GaiaOidcClient> gaiaEndpoint;
private readonly ServiceEndpoint<WorkforcePoolClient> stsEndpoint;
private readonly IOidcOfflineCredentialStore offlineStore;
private readonly IRepository<IAccessSettings> accessSettings;
private readonly UserAgent userAgent;
private CancellationTokenSource? cancelCurrentSignin = null;
public AuthorizeViewModel(
ServiceEndpoint<GaiaOidcClient> gaiaEndpoint,
ServiceEndpoint<WorkforcePoolClient> stsEndpoint,
IInstall install,
IOidcOfflineCredentialStore offlineStore,
IRepository<IAccessSettings> accessSettings,
HelpClient helpClient,
UserAgent userAgent)
{
this.gaiaEndpoint = gaiaEndpoint.ExpectNotNull(nameof(gaiaEndpoint));
this.stsEndpoint = stsEndpoint.ExpectNotNull(nameof(stsEndpoint));
this.offlineStore = offlineStore.ExpectNotNull(nameof(offlineStore));
this.accessSettings = accessSettings.ExpectNotNull(nameof(accessSettings));
this.userAgent = userAgent.ExpectNotNull(nameof(userAgent));
//
// NB. Properties are accessed from a non-GUI thread, so
// they must be thread-safe.
//
this.WindowTitle = ObservableProperty.Build($"Sign in - {Install.ProductName}");
this.IntroductionText = ObservableProperty.Build(
"Sign in to access your \r\nGoogle Cloud VMs.");
this.Version = ObservableProperty.Build($"Version {install.CurrentVersion}");
this.IsShowOptionsMenuEnabled = ObservableProperty.Build(true);
this.IsWaitControlVisible = ObservableProperty.Build(false, this);
this.IsSignOnControlVisible = ObservableProperty.Build(true, this);
this.IsCancelButtonVisible = ObservableProperty.Build(false, this);
this.IsChromeSingnInButtonEnabled = ObservableProperty.Build(ChromeBrowser.IsAvailable);
this.IsAuthorizationComplete = ObservableProperty.Build(false, this);
this.HelpCommand = ObservableCommand.Build(
string.Empty,
() => helpClient.OpenTopic(HelpTopics.SignInTroubleshooting));
this.CancelSignInCommand = ObservableCommand.Build(
string.Empty,
CancelSignIn);
this.TryLoadExistingAuthorizationCommand = ObservableCommand.Build(
string.Empty,
TryLoadExistingAuthorizationAsync);
this.SignInWithDefaultBrowserCommand = ObservableCommand.Build(
string.Empty,
() => SignInAsync(BrowserPreference.Default));
this.SignInWithChromeCommand = ObservableCommand.Build(
string.Empty,
() => SignInAsync(BrowserPreference.Chrome),
this.IsChromeSingnInButtonEnabled);
this.SignInWithChromeGuestModeCommand = ObservableCommand.Build(
string.Empty,
() => SignInAsync(BrowserPreference.ChromeGuest),
this.IsChromeSingnInButtonEnabled);
this.ShowOptionsCommand = ObservableCommand.Build(
string.Empty,
() => this.ShowOptions?.Invoke(this, EventArgs.Empty),
this.IsShowOptionsMenuEnabled);
}
private protected virtual Authorization CreateAuthorization()
{
Debug.Assert(this.Authorization == null);
Debug.Assert(this.DeviceEnrollment != null);
Debug.Assert(this.ClientRegistrations != null);
Precondition.ExpectNotNull(this.DeviceEnrollment, nameof(this.DeviceEnrollment));
Precondition.ExpectNotNull(this.ClientRegistrations, nameof(this.ClientRegistrations));
OidcIssuer issuer;
WorkforcePoolProviderLocator? providerLocator = null;
if (this.accessSettings.GetSettings().WorkforcePoolProvider.Value
is var provider &&
!string.IsNullOrEmpty(provider) &&
WorkforcePoolProviderLocator.TryParse(provider, out providerLocator))
{
//
// Use workforce identity.
//
issuer = OidcIssuer.Sts;
}
else
{
//
// Default to Gaia.
//
issuer = OidcIssuer.Gaia;
}
var registration = this.ClientRegistrations
.EnsureNotNull()
.FirstOrDefault(r => r.Issuer == issuer);
if (registration == null)
{
throw new ArgumentException(
$"Missing client registration for issuer {issuer}");
}
IOidcClient client;
if (registration.Issuer == OidcIssuer.Sts)
{
Debug.Assert(providerLocator != null);
client = new WorkforcePoolClient(
this.stsEndpoint,
this.DeviceEnrollment.ExpectNotNull(nameof(this.DeviceEnrollment)),
this.offlineStore,
providerLocator!,
registration,
this.userAgent);
}
else
{
client = new GaiaOidcClient(
this.gaiaEndpoint,
this.DeviceEnrollment.ExpectNotNull(nameof(this.DeviceEnrollment)),
this.offlineStore,
registration,
this.userAgent);
}
return new Authorization(
client,
this.DeviceEnrollment.ExpectNotNull(nameof(this.DeviceEnrollment)));
}
//---------------------------------------------------------------------
// Events to interact with view.
//---------------------------------------------------------------------
/// <summary>
/// One or more requires scopes haven't been granted.
/// </summary>
public EventHandler<RecoverableExceptionEventArgs>? OAuthScopeNotGranted;
/// <summary>
/// An error occurred that might be due to network misconfiguration.
/// </summary>
public EventHandler<RecoverableExceptionEventArgs>? NetworkError;
/// <summary>
/// User requested to show options.
/// </summary>
public EventHandler<EventArgs>? ShowOptions;
//---------------------------------------------------------------------
// Input properties.
//---------------------------------------------------------------------
/// <summary>
/// Device enrollment, must be initialized.
/// </summary>
public IDeviceEnrollment? DeviceEnrollment { get; set; }
/// <summary>
/// List of client registrations. There must be at least one
/// registration for each supported issuer.
/// </summary>
public IList<OidcClientRegistration>? ClientRegistrations { get; set; }
//---------------------------------------------------------------------
// Observable properties.
//---------------------------------------------------------------------
public ObservableProperty<string> WindowTitle { get; }
public ObservableProperty<string> IntroductionText { get; }
public ObservableProperty<string> Version { get; }
public ObservableProperty<bool> IsWaitControlVisible { get; }
public ObservableProperty<bool> IsSignOnControlVisible { get; }
public ObservableProperty<bool> IsCancelButtonVisible { get; }
public ObservableProperty<bool> IsChromeSingnInButtonEnabled { get; }
public ObservableProperty<bool> IsShowOptionsMenuEnabled { get; }
public ObservableProperty<bool> IsAuthorizationComplete { get; }
//---------------------------------------------------------------------
// Input/output properties.
//---------------------------------------------------------------------
/// <summary>
/// Authorization
/// * If set to null, a new Authorization is created.
/// * If non-null, a reauthorization is performed.
/// </summary>
public IAuthorization? Authorization { get; private set; }
/// <summary>
/// Reauthorize using an existing authorization object.
/// </summary>
public void UseExistingAuthorization(IAuthorization authorization)
{
Debug.Assert(this.Authorization == null);
this.Authorization = authorization.ExpectNotNull(nameof(authorization));
this.WindowTitle.Value = "Session expired";
this.IsShowOptionsMenuEnabled.Value = false;
this.IntroductionText.Value =
"Your session has expired.\nSign in again to continue using IAP Destop.";
}
//---------------------------------------------------------------------
// Observable commands.
//---------------------------------------------------------------------
public ObservableCommand HelpCommand { get; }
public ObservableCommand CancelSignInCommand { get; }
public ObservableCommand TryLoadExistingAuthorizationCommand { get; }
public ObservableCommand SignInWithDefaultBrowserCommand { get; }
public ObservableCommand SignInWithChromeCommand { get; }
public ObservableCommand SignInWithChromeGuestModeCommand { get; }
public ObservableCommand ShowOptionsCommand { get; }
//---------------------------------------------------------------------
// Sign-in logic.
//---------------------------------------------------------------------
private void CancelSignIn()
{
Debug.Assert(this.cancelCurrentSignin != null);
this.cancelCurrentSignin?.Cancel();
}
private Task TryLoadExistingAuthorizationAsync()
{
this.IsSignOnControlVisible.Value = false;
this.IsWaitControlVisible.Value = true;
this.IsCancelButtonVisible.Value = false;
//
// This method is called on the GUI thread, but we don't want to
// block that. So continue on a background thread, but force
// all events back to the GUI thread.
//
return Task.Run(async () =>
{
try
{
var authorization = CreateAuthorization();
if (await authorization
.TryAuthorizeSilentlyAsync(CancellationToken.None)
.ConfigureAwait(false))
{
//
// We have existing credentials, there is no need to even
// show the "Sign In" button.
//
this.Authorization = authorization;
this.IsAuthorizationComplete.Value = true;
}
else
{
//
// No valid credentials present, request user to authroize
// by showing the "Sign In" button.
//
this.IsSignOnControlVisible.Value = true;
this.IsWaitControlVisible.Value = false;
}
}
catch (Exception)
{
//
// Something went wrong trying to load existing credentials.
//
this.IsSignOnControlVisible.Value = true;
this.IsWaitControlVisible.Value = false;
}
});
}
private async Task SignInAsync(BrowserPreference browserPreference)
{
this.cancelCurrentSignin?.Dispose();
this.cancelCurrentSignin = new CancellationTokenSource();
this.IsSignOnControlVisible.Value = false;
this.IsWaitControlVisible.Value = true;
this.IsCancelButtonVisible.Value = true;
try
{
var retry = true;
while (retry)
{
try
{
Authorization authorization;
if (this.Authorization == null)
{
//
// First-time authorization.
//
authorization = CreateAuthorization();
}
else
{
//
// We're reauthorizing. Don't let the user change issuers.
//
authorization = (Authorization)this.Authorization;
}
await authorization
.AuthorizeAsync(
browserPreference,
this.cancelCurrentSignin.Token)
.ConfigureAwait(true);
//
// Authorization successful.
//
retry = false;
this.Authorization = authorization;
this.IsAuthorizationComplete.Value = true;
}
catch (OAuthScopeNotGrantedException e)
{
var args = new RecoverableExceptionEventArgs(e);
this.OAuthScopeNotGranted?.Invoke(this, args);
retry = args.Retry;
}
catch (Exception e) when (!e.IsCancellation())
{
var args = new RecoverableExceptionEventArgs(e);
this.NetworkError?.Invoke(this, args);
retry = args.Retry;
}
}
}
finally
{
this.IsSignOnControlVisible.Value = true;
this.IsWaitControlVisible.Value = false;
this.IsCancelButtonVisible.Value = false;
}
}
}
}