diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/ServiceAccountCredentialTests.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ServiceAccountCredentialTests.cs index 86553409394..4cea2433fbe 100644 --- a/Src/Support/Google.Apis.Auth.Tests/OAuth2/ServiceAccountCredentialTests.cs +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ServiceAccountCredentialTests.cs @@ -686,17 +686,29 @@ public void WithUseJwtAccessWithScopes() Assert.True(credentialWithJwtFlag.UseJwtAccessWithScopes); } - [Fact] - public async Task FetchesOidcToken() + public static TheoryData OidcData => new TheoryData + { + // universe domain, token URL, SA id, expected OIDC URL + { null, "http://will.be.ignored", "MyId", "http://will.be.ignored/" }, + { "fake.domain", "http://will.be.ignored", "MyId", "https://iamcredentials.fake.domain/v1/projects/-/serviceAccounts/MyId:generateIdToken" } + }; + + [Theory] + [MemberData(nameof(OidcData))] + public async Task FetchesOidcToken(string universeDomain, string tokenUrl, string saId, string expectedOidcUrl) { // A little bit after the tokens returned from OidcTokenFakes were issued. var clock = new MockClock(new DateTime(2020, 5, 13, 15, 0, 0, 0, DateTimeKind.Utc)); var messageHandler = new OidcTokenResponseSuccessMessageHandler(); - var initializer = new ServiceAccountCredential.Initializer("MyId", "http://will.be.ignored") + var initializer = new ServiceAccountCredential.Initializer(saId, tokenUrl) { Clock = clock, ProjectId = "a_project_id", - HttpClientFactory = new MockHttpClientFactory(messageHandler) + HttpClientFactory = new MockHttpClientFactory(messageHandler), + UniverseDomain = universeDomain, + // Some of the tests are for other than the default universe domain, for which only self-signed JWTs are supported. + // This won't affect OIDC tests as it's relevant only for access token, but the credential constructor validates it. + UseJwtAccessWithScopes = true }; var credential = new ServiceAccountCredential(initializer.FromPrivateKey(PrivateKey)); @@ -706,6 +718,7 @@ public async Task FetchesOidcToken() var signedToken = SignedToken.FromSignedToken(await oidcToken.GetAccessTokenAsync()); Assert.Equal("https://first_call.test", signedToken.Payload.Audience); + Assert.Equal(expectedOidcUrl, messageHandler.LatestRequest.RequestUri.AbsoluteUri); // Move the clock some but not enough that the token expires. clock.UtcNow = clock.UtcNow.AddMinutes(20); signedToken = SignedToken.FromSignedToken(await oidcToken.GetAccessTokenAsync()); @@ -714,17 +727,22 @@ public async Task FetchesOidcToken() Assert.Equal(1, messageHandler.Calls); } - [Fact] - public async Task RefreshesOidcToken() + [Theory] + [MemberData(nameof(OidcData))] + public async Task RefreshesOidcToken(string universeDomain, string tokenUrl, string saId, string expectedOidcUrl) { // A little bit after the tokens returned from OidcTokenFakes were issued. var clock = new MockClock(new DateTime(2020, 5, 13, 15, 0, 0, 0, DateTimeKind.Utc)); var messageHandler = new OidcTokenResponseSuccessMessageHandler(); - var initializer = new ServiceAccountCredential.Initializer("MyId", "http://will.be.ignored") + var initializer = new ServiceAccountCredential.Initializer(saId, tokenUrl) { Clock = clock, ProjectId = "a_project_id", - HttpClientFactory = new MockHttpClientFactory(messageHandler) + HttpClientFactory = new MockHttpClientFactory(messageHandler), + UniverseDomain = universeDomain, + // Some of the tests are for other than the default universe domain, for which only self-signed JWTs are supported. + // This won't affect OIDC tests as it's relevant only for access token, but the credential constructor validates it. + UseJwtAccessWithScopes = true }; var credential = new ServiceAccountCredential(initializer.FromPrivateKey(PrivateKey)); @@ -736,6 +754,7 @@ public async Task RefreshesOidcToken() clock.UtcNow = clock.UtcNow.AddHours(2); signedToken = SignedToken.FromSignedToken(await oidcToken.GetAccessTokenAsync()); Assert.Equal("https://subsequent_calls.test", signedToken.Payload.Audience); + Assert.Equal(expectedOidcUrl, messageHandler.LatestRequest.RequestUri.AbsoluteUri); // Two calls, because the second time we tried to get the token, the first one had expired. Assert.Equal(2, messageHandler.Calls); } diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs index 7c596aca718..b383ef84bba 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs @@ -170,6 +170,17 @@ public Initializer FromCertificate(X509Certificate2 certificate) /// bool IGoogleCredential.SupportsExplicitScopes => true; + /// + /// The URL to obtain an id token from when in a universe domain other than the default universe domain. + /// + private string IamOidcTokenUrl { get; } + + /// + /// HttpClient used to call the IAM API, authenticated as this credential. + /// + /// Lazy to build one HtppClient only if it is needed. + private readonly Lazy _iamHttpClientCache; + /// Constructs a new service account credential using the given initializer. public ServiceAccountCredential(Initializer initializer) : base(initializer) { @@ -184,6 +195,8 @@ public ServiceAccountCredential(Initializer initializer) : base(initializer) Key = initializer.Key.ThrowIfNull("initializer.Key"); KeyId = initializer.KeyId; UseJwtAccessWithScopes = initializer.UseJwtAccessWithScopes; + IamOidcTokenUrl = string.Format(GoogleAuthConsts.IamIdTokenEndpointFormatString, UniverseDomain, Id); + _iamHttpClientCache = new Lazy(BuildIamHttpClientUncached); } /// @@ -323,14 +336,15 @@ public override async Task GetAccessTokenForRequestAsync(string authUri /// public Task GetOidcTokenAsync(OidcTokenOptions options, CancellationToken cancellationToken = default) { - GoogleAuthConsts.CheckIsDefaultUniverseDomain(UniverseDomain, $"ID tokens are not currently supported in universes other than {GoogleAuthConsts.DefaultUniverseDomain}."); - options.ThrowIfNull(nameof(options)); + Func> effectiveRefresh = + UniverseDomain == GoogleAuthConsts.DefaultUniverseDomain ? RefreshDefultUniverseOidcTokenAsync : RefreshIamOidcTokenAsync; + // If at some point some properties are added to OidcToken that depend on the token having been fetched // then initialize the token here. TokenRefreshManager tokenRefreshManager = null; tokenRefreshManager = new TokenRefreshManager( - ct => RefreshDefultUniverseOidcTokenAsync(tokenRefreshManager, options, ct), Clock, Logger); + ct => effectiveRefresh(tokenRefreshManager, options, ct), Clock, Logger); return Task.FromResult(new OidcToken(tokenRefreshManager)); } @@ -350,6 +364,30 @@ private async Task RefreshDefultUniverseOidcTokenAsync(TokenRefreshManager return true; } + private async Task RefreshIamOidcTokenAsync(TokenRefreshManager caller, OidcTokenOptions options, CancellationToken cancellationToken) + { + var request = new IamOIdCTokenRequest + { + Audience = options.TargetAudience, + IncludeEmail = true + }; + + caller.Token = await request.PostJsonAsync(_iamHttpClientCache.Value, IamOidcTokenUrl, Clock, Logger, cancellationToken) + .ConfigureAwait(false); + + return true; + } + + private ConfigurableHttpClient BuildIamHttpClientUncached() + { + // We want to use the same HTTP client configuration used for the standard HTTP client used by this credential. + // But we need to copy them because this credential is going to be one of the initializers, as the IAM HTTP client + // needs to be authenticated by this credential. + var httpClientArgs = BuildCreateHttpClientArgs(); + httpClientArgs.Initializers.Add(((IGoogleCredential)this).MaybeWithScopes(new string[] { GoogleAuthConsts.IamScope })); + return HttpClientFactory.CreateHttpClient(httpClientArgs); + } + private class JwtCacheEntry { public JwtCacheEntry(Task jwtTask, string uri, DateTime expiryUtc)