Skip to content

Commit

Permalink
feat: Support ID token for SAs in other than the default universe domain
Browse files Browse the repository at this point in the history
  • Loading branch information
amanda-tarafa committed Jan 28, 2025
1 parent 4fdf600 commit cf0e35a
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -686,17 +686,29 @@ public void WithUseJwtAccessWithScopes()
Assert.True(credentialWithJwtFlag.UseJwtAccessWithScopes);
}

[Fact]
public async Task FetchesOidcToken()
public static TheoryData<string, string, string, string> OidcData => new TheoryData<string, string, string, string>
{
// 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));

Expand All @@ -706,6 +718,7 @@ public async Task FetchesOidcToken()

var signedToken = SignedToken<Header, Payload>.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<Header, Payload>.FromSignedToken(await oidcToken.GetAccessTokenAsync());
Expand All @@ -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));

Expand All @@ -736,6 +754,7 @@ public async Task RefreshesOidcToken()
clock.UtcNow = clock.UtcNow.AddHours(2);
signedToken = SignedToken<Header, Payload>.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);
}
Expand Down
44 changes: 41 additions & 3 deletions Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ public Initializer FromCertificate(X509Certificate2 certificate)
/// <inheritdoc/>
bool IGoogleCredential.SupportsExplicitScopes => true;

/// <summary>
/// The URL to obtain an id token from when in a universe domain other than the default universe domain.
/// </summary>
private string IamOidcTokenUrl { get; }

/// <summary>
/// HttpClient used to call the IAM API, authenticated as this credential.
/// </summary>
/// <remarks>Lazy to build one HtppClient only if it is needed.</remarks>
private readonly Lazy<ConfigurableHttpClient> _iamHttpClientCache;

/// <summary>Constructs a new service account credential using the given initializer.</summary>
public ServiceAccountCredential(Initializer initializer) : base(initializer)
{
Expand All @@ -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<ConfigurableHttpClient>(BuildIamHttpClientUncached);
}

/// <summary>
Expand Down Expand Up @@ -323,14 +336,15 @@ public override async Task<string> GetAccessTokenForRequestAsync(string authUri
/// <inheritdoc/>
public Task<OidcToken> 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<TokenRefreshManager, OidcTokenOptions, CancellationToken, Task<bool>> 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));
}

Expand All @@ -350,6 +364,30 @@ private async Task<bool> RefreshDefultUniverseOidcTokenAsync(TokenRefreshManager
return true;
}

private async Task<bool> 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<string> jwtTask, string uri, DateTime expiryUtc)
Expand Down

0 comments on commit cf0e35a

Please sign in to comment.