sources/Google.Solutions.Apis/Compute/OsLoginClient.cs (486 lines of code) (raw):

// // Copyright 2020 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.CloudOSLogin.v1; using Google.Apis.CloudOSLogin.v1.Data; using Google.Apis.Discovery; using Google.Apis.Requests; using Google.Apis.Services; using Google.Apis.Util; 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.Apis.Diagnostics; using Google.Solutions.Apis.Locator; using Google.Solutions.Common.Diagnostics; using Google.Solutions.Common.Linq; using Google.Solutions.Common.Util; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using static Google.Solutions.Apis.Compute.OsLoginClient; namespace Google.Solutions.Apis.Compute { /// <summary> /// Client for OS Login API. /// </summary> public interface IOsLoginClient : IClient { /// <summary> /// Import user's public key to OS Login. /// </summary> /// <param name="key">public key, in OpenSSH format</param> Task<LoginProfile> ImportSshPublicKeyAsync( ProjectLocator project, string key, TimeSpan validity, CancellationToken token); /// <summary> /// Certify a user's public key. /// </summary> /// <param name="zone"></param> /// <param name="key">public key, in OpenSSH format</param> /// <returns></returns> Task<string> SignPublicKeyAsync( ZoneLocator zone, string key, CancellationToken cancellationToken); /// <summary> /// Read user's profile and published SSH keys. /// </summary> Task<LoginProfile> GetLoginProfileAsync( ProjectLocator project, CancellationToken token); /// <summary> /// Delete existing authorized key. /// </summary> Task DeleteSshPublicKeyAsync( string fingerprint, CancellationToken cancellationToken); /// <summary> /// List enrolled U2F and WebAuthn security keys. /// </summary> Task<IList<SecurityKey>> ListSecurityKeysAsync( ProjectLocator project, CancellationToken cancellationToken); } public class OsLoginClient : ApiClientBase, IOsLoginClient { private readonly IAuthorization authorization; private readonly CloudOSLoginService service; public OsLoginClient( ServiceEndpoint<OsLoginClient> endpoint, IAuthorization authorization, ApiKey apiKey, UserAgent userAgent) : base(endpoint, authorization, userAgent) { if (authorization.Session is IWorkforcePoolSession) { // // When authenticating using workforce identity, we have // to pass an API key to charge against. // this.Initializer.ApiKey = apiKey.Value; } this.authorization = authorization.ExpectNotNull(nameof(authorization)); this.service = new CloudOSLoginService(this.Initializer); } public static ServiceEndpoint<OsLoginClient> CreateEndpoint( ServiceRoute? route = null) { return new ServiceEndpoint<OsLoginClient>( route ?? ServiceRoute.Public, "https://oslogin.googleapis.com/"); } internal string EncodedUserPathComponent { get => this.authorization.Session switch { // // Use the email address without extra encoding. // IGaiaOidcSession gaiaSession => gaiaSession.Email, // // Use the full principal idenfifier (yes, that's a URL) // and encode it. // IWorkforcePoolSession wfSession => WebUtility.UrlEncode(wfSession.PrincipalIdentifier), _ => throw new ArgumentOutOfRangeException() }; } //--------------------------------------------------------------------- // IOsLoginClient. //--------------------------------------------------------------------- public async Task<LoginProfile> ImportSshPublicKeyAsync( ProjectLocator project, string key, TimeSpan validity, CancellationToken token) { project.ExpectNotNull(nameof(project)); key.ExpectNotEmpty(nameof(key)); Debug.Assert(key.Contains(' ')); if (this.authorization.Session is IWorkforcePoolSession) { throw new OsLoginNotSupportedForWorkloadIdentityException(); } using (ApiTraceSource.Log.TraceMethod().WithParameters(project)) { var expiryTimeUsec = new DateTimeOffset(DateTime.UtcNow.Add(validity)) .ToUnixTimeMilliseconds() * 1000; var request = this.service.Users.ImportSshPublicKey( new SshPublicKey() { Key = key, ExpirationTimeUsec = expiryTimeUsec }, $"users/{this.EncodedUserPathComponent}"); request.ProjectId = project.ProjectId; try { var response = await request .ExecuteAsync(token) .ConfigureAwait(false); // // Creating the profile succeeded (if it didn't exist // yet -- but we still need to check if the key was actually // added. // // If the 'Allow users to manage their SSH public keys // via the OS Login API' policy is disabled (in Cloud Identity), // then adding the key won't work. // if (response.LoginProfile.SshPublicKeys .EnsureNotNull() .Any(kvp => kvp.Value.Key.Contains(key))) { return response.LoginProfile; } else { // // Key wasn't added. // throw new ResourceAccessDeniedException( "You do not have sufficient permissions to publish an SSH " + "key to OS Login", HelpTopics.ManagingOsLogin, new GoogleApiException("oslogin", response.Details ?? string.Empty)); } } catch (GoogleApiException e) when (e.IsAccessDenied()) { // // Likely reason: The user account is a consumer account or // an administrator has disabled POSIX account/SSH key information // updates in the Admin Console. // throw new ResourceAccessDeniedException( "You do not have sufficient permissions to use OS Login: " + e.Error?.Message ?? "access denied", HelpTopics.ManagingOsLogin, e); } } } public async Task<LoginProfile> GetLoginProfileAsync( ProjectLocator project, CancellationToken token) { using (ApiTraceSource.Log.TraceMethod().WithParameters(project)) { if (this.authorization.Session is IWorkforcePoolSession) { throw new OsLoginNotSupportedForWorkloadIdentityException(); } var request = this.service.Users.GetLoginProfile( $"users/{this.EncodedUserPathComponent}"); request.ProjectId = project.ProjectId; try { return await request .ExecuteAsync(token) .ConfigureAwait(false); } catch (GoogleApiException e) when (e.IsAccessDenied()) { throw new ResourceAccessDeniedException( "You do not have sufficient permissions to use OS Login: " + e.Error?.Message ?? "access denied", HelpTopics.ManagingOsLogin, e); } catch (GoogleApiException e) when (e.IsNotFound()) { throw new ResourceNotFoundException( "The login profile could not be found, it it has not " + "been allocated yet", e); } } } public async Task DeleteSshPublicKeyAsync( string fingerprint, CancellationToken cancellationToken) { using (ApiTraceSource.Log.TraceMethod().WithParameters(fingerprint)) { if (this.authorization.Session is IWorkforcePoolSession) { throw new OsLoginNotSupportedForWorkloadIdentityException(); } try { await this.service.Users.SshPublicKeys .Delete($"users/{this.EncodedUserPathComponent}/sshPublicKeys/{fingerprint}") .ExecuteAsync(cancellationToken) .ConfigureAwait(false); } catch (GoogleApiException e) when (e.IsAccessDenied()) { throw new ResourceAccessDeniedException( "You do not have sufficient permissions to use OS Login: " + e.Error?.Message ?? "access denied", HelpTopics.ManagingOsLogin, e); } } } public async Task<string> SignPublicKeyAsync( ZoneLocator zone, string key, CancellationToken cancellationToken) { using (ApiTraceSource.Log.TraceMethod().WithParameters(zone)) { try { var request = new BetaSignSshPublicKeyRequest( this.service, new BetaSignSshPublicKeyRequestData() { SshPublicKey = key }, $"users/{this.EncodedUserPathComponent}/projects/{zone.ProjectId}/locations/{zone.Name}"); if (this.authorization.Session is IWorkforcePoolSession) { // // This is a non-resourceful API. Charging to a client // project doesn't work with workforce identity, so we // have to do one of the following: // // (1) Pass an API key that's from the same project as the // OAuth client. // // (2) Pass an API key from any project, and set the // quota project. // // This requires the user to have the // serviceusage.services.use permission. // // Option (1) isn't viable currently, so we need to do (2). // request.UserProject = zone.ProjectId; } var response = await request .ExecuteAsync(cancellationToken) .ConfigureAwait(false); Invariant.ExpectNotNull( response.SignedSshPublicKey, "SignedSshPublicKey"); return response.SignedSshPublicKey!; } catch (GoogleApiException e) when ( e.Error != null && e.Error.Code == 400 && e.Error.Message != null && e.Error.Message.Contains("google.posix_username")) { throw new ExternalIdpNotConfiguredForOsLoginException( "Your workforce identity provider configuration doesn't contain " + "an attribute mapping for 'google.posix_username'. This mapping is " + "required for using OS Login.", e); } catch (GoogleApiException e) when (e.IsAccessDenied()) { if (e.Error?.Message is var message && message != null && message.Contains("roles/serviceusage.serviceUsageConsumer")) { throw new ResourceAccessDeniedException( "You do not have sufficient access to log in.\n\n" + "Because you've authenticated using workforce identity federation, " + "you additionally need the 'Service Usage Consumer' role " + "(or an equivalent custom role) to log in.", HelpTopics.UseOsLoginWithWorkforceIdentity, e); } else { throw new ResourceAccessDeniedException( "You do not have sufficient access to log in: " + e.Error?.Message ?? "access denied", HelpTopics.ManagingOsLogin, e); } } } } public async Task<IList<SecurityKey>> ListSecurityKeysAsync( ProjectLocator project, CancellationToken cancellationToken) { using (ApiTraceSource.Log.TraceMethod().WithParameters(project)) { if (this.authorization.Session is IWorkforcePoolSession) { throw new OsLoginNotSupportedForWorkloadIdentityException(); } try { var request = new BetaGetLoginProfileRequest( this.service, $"users/{this.EncodedUserPathComponent}") { ProjectId = project.Name, View = BetaGetLoginProfileRequest.ViewEnum.SECURITYKEY }; var response = await request .ExecuteAsync(cancellationToken) .ConfigureAwait(false); return response.SecurityKeys ?? new List<SecurityKey>(); } catch (GoogleApiException e) when (e.IsAccessDenied()) { throw new ResourceAccessDeniedException( "You do not have sufficient permissions to use OS Login: " + e.Error?.Message ?? "access denied", HelpTopics.ManagingOsLogin, e); } } } //--------------------------------------------------------------------- // v1beta1 entities. These can be removed once the methods have been // promoted to v1. //--------------------------------------------------------------------- #region Request entities private class BetaSignSshPublicKeyResponseData : IDirectResponseSchema { [JsonProperty("signedSshPublicKey")] public virtual string? SignedSshPublicKey { get; set; } public virtual string? ETag { get; set; } } private class BetaSignSshPublicKeyRequestData : IDirectResponseSchema { [JsonProperty("sshPublicKey")] public virtual string? SshPublicKey { get; set; } public virtual string? ETag { get; set; } } private class BetaSignSshPublicKeyRequest : CloudOSLoginBaseServiceRequest<BetaSignSshPublicKeyResponseData> { [RequestParameter("parent")] public virtual string Parent { get; private set; } private BetaSignSshPublicKeyRequestData Body { get; set; } public override string MethodName => "signSshPublicKey"; public override string HttpMethod => "POST"; public override string RestPath => "v1beta/{+parent}:signSshPublicKey"; [RequestParameter("$userProject")] public virtual string? UserProject { get; set; } public BetaSignSshPublicKeyRequest( IClientService service, BetaSignSshPublicKeyRequestData body, string parent) : base(service) { this.Parent = parent; this.Body = body; InitParameters(); } protected override object GetBody() { return this.Body; } protected override void InitParameters() { base.InitParameters(); this.RequestParameters.Add("parent", new Parameter { Name = "parent", IsRequired = true, ParameterType = "path", DefaultValue = null, Pattern = "^users/[^/]+/projects/[^/]+/locations/[^/]+$" }); this.RequestParameters.Add("$userProject", new Parameter { Name = "$userProject", IsRequired = false, ParameterType = "query", DefaultValue = null }); } } private class BetaLoginProfile : IDirectResponseSchema { /// <summary>The registered security key credentials for a user.</summary> [JsonProperty("securityKeys")] public virtual IList<SecurityKey>? SecurityKeys { get; set; } public virtual string? ETag { get; set; } } public class SecurityKey : IDirectResponseSchema { [JsonProperty("deviceNickname")] public virtual string? DeviceNickname { get; set; } [JsonProperty("privateKey")] public virtual string? PrivateKey { get; set; } [JsonProperty("publicKey")] public virtual string? PublicKey { get; set; } [JsonProperty("universalTwoFactor")] public virtual UniversalTwoFactor? UniversalTwoFactor { get; set; } [JsonProperty("webAuthn")] public virtual WebAuthn? WebAuthn { get; set; } public virtual string? ETag { get; set; } } public class UniversalTwoFactor : IDirectResponseSchema { [JsonProperty("appId")] public virtual string? AppId { get; set; } public virtual string? ETag { get; set; } } public class WebAuthn : IDirectResponseSchema { [JsonProperty("rpId")] public virtual string? RpId { get; set; } public virtual string? ETag { get; set; } } private class BetaGetLoginProfileRequest : CloudOSLoginBaseServiceRequest<BetaLoginProfile> { public enum ViewEnum { [StringValue("SECURITY_KEY")] SECURITYKEY } [RequestParameter("name")] public virtual string Name { get; private set; } [RequestParameter("projectId")] public virtual string? ProjectId { get; set; } [RequestParameter("systemId")] public virtual string? SystemId { get; set; } [RequestParameter("view")] public virtual ViewEnum? View { get; set; } [RequestParameter("$userProject")] public virtual string? UserProject { get; set; } public override string MethodName => "getLoginProfile"; public override string HttpMethod => "GET"; public override string RestPath => "v1beta/{+name}/loginProfile"; public BetaGetLoginProfileRequest(IClientService service, string name) : base(service) { this.Name = name; InitParameters(); } protected override void InitParameters() { base.InitParameters(); this.RequestParameters.Add("name", new Parameter { Name = "name", IsRequired = true, ParameterType = "path", DefaultValue = null, Pattern = "^users/[^/]+$" }); this.RequestParameters.Add("projectId", new Parameter { Name = "projectId", IsRequired = false, ParameterType = "query", DefaultValue = null, Pattern = null }); this.RequestParameters.Add("systemId", new Parameter { Name = "systemId", IsRequired = false, ParameterType = "query", DefaultValue = null, Pattern = null }); this.RequestParameters.Add("view", new Parameter { Name = "view", IsRequired = false, ParameterType = "query", DefaultValue = null, Pattern = null }); this.RequestParameters.Add("$userProject", new Parameter { Name = "$userProject", IsRequired = false, ParameterType = "query", DefaultValue = null }); } } #endregion } internal class OsLoginNotSupportedForWorkloadIdentityException : NotSupportedForWorkloadIdentityException, IExceptionWithHelpTopic { public OsLoginNotSupportedForWorkloadIdentityException() : base( "This OS Login operation is not supported for " + "workforce identity federation.") { } } public class ExternalIdpNotConfiguredForOsLoginException : ClientException, IExceptionWithHelpTopic { public IHelpTopic? Help { get; } public ExternalIdpNotConfiguredForOsLoginException( string message, Exception inner) : base(message, inner) { this.Help = HelpTopics.UseOsLoginWithWorkforceIdentity; } } }