wwauth/Google.Solutions.WWAuth/Adapters/ServiceAccountAdapter.cs (193 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.Apis.Auth.OAuth2; using Google.Apis.Auth.OAuth2.Responses; using Google.Apis.IAMCredentials.v1; using Google.Apis.IAMCredentials.v1.Data; using Google.Apis.Logging; using Google.Apis.Services; using Google.Apis.Util; using Google.Solutions.WWAuth.Data; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; namespace Google.Solutions.WWAuth.Adapters { /// <summary> /// Adapter for Service Account API. /// </summary> public interface IServiceAccountAdapter { bool IsEnabled { get; } string ServiceAccountEmail { get; } /// <summary> /// Check if the service account exists. /// </summary> Task<bool> ExistsAsync( CancellationToken cancellationToken); /// <summary> /// Impersonate the service account. /// </summary> Task<TokenResponse> ImpersonateAsync( string stsToken, IList<string> scopes, CancellationToken cancellationToken); /// <summary> /// Introspect access token. /// </summary> Task<ISubjectToken> IntrospectTokenAsync( string accessToken, CancellationToken cancellationToken); } internal class ServiceAccountAdapter : IServiceAccountAdapter { private readonly ILogger logger; public bool IsEnabled => !string.IsNullOrEmpty(this.ServiceAccountEmail); public string ServiceAccountEmail { get; } private static HttpClient CreateHttpClient() { var client = new HttpClient(); client.DefaultRequestHeaders.UserAgent.Add( new ProductInfoHeaderValue( UserAgent.Default.Name, UserAgent.Default.Version)); return client; } public ServiceAccountAdapter( string serviceAccountEmail, ILogger logger) { this.ServiceAccountEmail = serviceAccountEmail; this.logger = logger.ThrowIfNull(nameof(logger)); } /// <summary> /// Check if the service account exists. /// </summary> /// <param name="cancellationToken"></param> /// <returns></returns> public async Task<bool> ExistsAsync( CancellationToken cancellationToken) { // // If the service account email address is valid, then // there must be a public JWKS. // // N.B. We don't have any Google credentials, so using // the IAM API isn't an option. // try { this.logger.Info( "Fetching JWKS for service account '{0}'", this.ServiceAccountEmail); using (var client = CreateHttpClient()) using (var response = await client.GetAsync( new Uri("https://www.googleapis.com/service_accounts/v1/" + $"metadata/jwk/{this.ServiceAccountEmail}"), cancellationToken) .ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); this.logger.Info( "JWKS for service account '{0}' found", this.ServiceAccountEmail); // // JWKS found, service account must exist. // return true; } } catch (HttpRequestException e) { this.logger.Error(e, "Failed to fetch JWKS for service account '{0}'", this.ServiceAccountEmail); return false; } } /// <summary> /// Impersonate service account using an STS token. /// </summary> public async Task<TokenResponse> ImpersonateAsync( string stsToken, IList<string> scopes, CancellationToken cancellationToken) { try { this.logger.Info( "Using STS token to impersonate service account '{0}'", this.ServiceAccountEmail); using (var service = new IAMCredentialsService( new BaseClientService.Initializer() { // // Use the STS token like an access token to authenticate // requests. // HttpClientInitializer = GoogleCredential.FromAccessToken(stsToken), ApplicationName = UserAgent.Default.ToString() })) { var response = await service.Projects.ServiceAccounts .GenerateAccessToken( new GenerateAccessTokenRequest() { Scope = scopes }, $"projects/-/serviceAccounts/{this.ServiceAccountEmail}") .ExecuteAsync(cancellationToken) .ConfigureAwait(false); this.logger.Info( "Successfully obtained access token for service account '{0}'", this.ServiceAccountEmail); return new TokenResponse() { AccessToken = response.AccessToken, ExpiresInSeconds = (long)(DateTime.UtcNow - DateTime.Parse(response.ExpireTime.ToString())).TotalSeconds }; } } catch (GoogleApiException e) when (e.Error?.Code == 403) { throw new TokenExchangeException( $"Insufficient permissions to impersonate service account '{this.ServiceAccountEmail}', " + $"the principal might be missing the 'Workload Identity User' role", e); } catch (GoogleApiException e) { this.logger.Error(e, "Failed to impersonate service account '{0}': {1}, Code: {2}, Details: {3}", this.ServiceAccountEmail, e.Message, e.Error?.Code, e.Error?.ErrorResponseContent); throw; } } /// <summary> /// Introspect token by calling the token info endpoint. /// </summary> public async Task<ISubjectToken> IntrospectTokenAsync( string accessToken, CancellationToken cancellationToken) { try { this.logger.Info("Introspecting access token"); using (var client = CreateHttpClient()) using (var response = await client.GetAsync( new Uri("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=" + accessToken), cancellationToken) .ConfigureAwait(false)) { response.EnsureSuccessStatusCode(); var body = await response.Content .ReadAsStringAsync() .ConfigureAwait(false); return new TokenInfo( accessToken, JsonConvert.DeserializeObject<Dictionary<string, object>>(body)); } } catch (HttpRequestException e) { throw new TokenExchangeException( "Token introspection failed", e); } } //--------------------------------------------------------------------- // Token info. //--------------------------------------------------------------------- private class TokenInfo : ISubjectToken { public SubjectTokenType Type => SubjectTokenType.IdToken; public string Value { get; } public string Issuer => "https://accounts.google.com/"; public DateTimeOffset? Expiry { get; } public IDictionary<string, object> Attributes { get; } public string Audience { get; } public bool IsEncrypted => false; public TokenInfo( string value, IDictionary<string, object> attributes) { this.Value = value; this.Attributes = attributes; if (attributes.TryGetValue("expires_in", out var expiresIn) && expiresIn is long expiresInSecs) { this.Expiry = DateTimeOffset.UtcNow.AddSeconds(expiresInSecs); } if (attributes.TryGetValue("audience", out var audience) && audience is string audienceValue) { this.Audience = audienceValue; } } } } }