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 bc70af9 commit ee422c6
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -687,12 +687,12 @@ public void WithUseJwtAccessWithScopes()
}

[Fact]
public async Task FetchesOidcToken()
public async Task FetchesOidcToken_DefaultUniverseDomain()
{
// 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("sa@domain")
{
Clock = clock,
ProjectId = "a_project_id",
Expand All @@ -706,6 +706,9 @@ public async Task FetchesOidcToken()

var signedToken = SignedToken<Header, Payload>.FromSignedToken(await oidcToken.GetAccessTokenAsync());
Assert.Equal("https://first_call.test", signedToken.Payload.Audience);
Assert.Equal(GoogleAuthConsts.OidcTokenUrl, messageHandler.LatestRequest.RequestUri.AbsoluteUri);
Assert.Null(messageHandler.LatestRequest.Headers.Authorization);
Assert.Contains("assertion", messageHandler.LatestRequestContent);
// 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 @@ -715,12 +718,45 @@ public async Task FetchesOidcToken()
}

[Fact]
public async Task RefreshesOidcToken()
public async Task FetchesOidcToken_NonDefaultUniverseDomain()
{
// 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("sa@domain")
{
Clock = clock,
ProjectId = "a_project_id",
HttpClientFactory = new MockHttpClientFactory(messageHandler),
UniverseDomain = "fake.domain",
UseJwtAccessWithScopes = true
};
var credential = new ServiceAccountCredential(initializer.FromPrivateKey(PrivateKey));

// The fake Oidc server returns valid tokens (expired in the real world for safety)
// but with a set audience that lets us know if the token was refreshed or not.
var oidcToken = await credential.GetOidcTokenAsync(OidcTokenOptions.FromTargetAudience("will.be.ignored"));

var signedToken = SignedToken<Header, Payload>.FromSignedToken(await oidcToken.GetAccessTokenAsync());
Assert.Equal("https://first_call.test", signedToken.Payload.Audience);
Assert.Equal("https://iamcredentials.fake.domain/v1/projects/-/serviceAccounts/sa@domain:generateIdToken", messageHandler.LatestRequest.RequestUri.AbsoluteUri);
Assert.NotNull(messageHandler.LatestRequest.Headers.Authorization);
Assert.Contains("audience", messageHandler.LatestRequestContent);
// 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());
Assert.Equal("https://first_call.test", signedToken.Payload.Audience);
// Only the first call should have resulted in a request. The second time the token hadn't expired.
Assert.Equal(1, messageHandler.Calls);
}

[Fact]
public async Task RefreshesOidcToken_DefaultUniverseDomain()
{
// 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("sa@domain")
{
Clock = clock,
ProjectId = "a_project_id",
Expand All @@ -736,6 +772,40 @@ 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(GoogleAuthConsts.OidcTokenUrl, messageHandler.LatestRequest.RequestUri.AbsoluteUri);
Assert.Null(messageHandler.LatestRequest.Headers.Authorization);
Assert.Contains("assertion", messageHandler.LatestRequestContent);
// Two calls, because the second time we tried to get the token, the first one had expired.
Assert.Equal(2, messageHandler.Calls);
}

[Fact]
public async Task RefreshesOidcToken_NonDefaultUniverseDomain()
{
// 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("sa@domain")
{
Clock = clock,
ProjectId = "a_project_id",
HttpClientFactory = new MockHttpClientFactory(messageHandler),
UniverseDomain = "fake.domain",
UseJwtAccessWithScopes = true
};
var credential = new ServiceAccountCredential(initializer.FromPrivateKey(PrivateKey));

var oidcToken = await credential.GetOidcTokenAsync(OidcTokenOptions.FromTargetAudience("audience"));

var signedToken = SignedToken<Header, Payload>.FromSignedToken(await oidcToken.GetAccessTokenAsync());
Assert.Equal("https://first_call.test", signedToken.Payload.Audience);
// Move the clock so that the token expires.
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("https://iamcredentials.fake.domain/v1/projects/-/serviceAccounts/sa@domain:generateIdToken", messageHandler.LatestRequest.RequestUri.AbsoluteUri);
Assert.NotNull(messageHandler.LatestRequest.Headers.Authorization);
Assert.Contains("audience", messageHandler.LatestRequestContent);
// Two calls, because the second time we tried to get the token, the first one had expired.
Assert.Equal(2, messageHandler.Calls);
}
Expand Down
46 changes: 42 additions & 4 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,18 +336,19 @@ 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 ? RefreshDefaultUniverseOidcTokenAsync : 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 => RefreshOidcTokenAsync(tokenRefreshManager, options, ct), Clock, Logger);
ct => effectiveRefresh(tokenRefreshManager, options, ct), Clock, Logger);
return Task.FromResult(new OidcToken(tokenRefreshManager));
}

private async Task<bool> RefreshOidcTokenAsync(TokenRefreshManager caller, OidcTokenOptions options, CancellationToken cancellationToken)
private async Task<bool> RefreshDefaultUniverseOidcTokenAsync(TokenRefreshManager caller, OidcTokenOptions options, CancellationToken cancellationToken)
{
var now = Clock.UtcNow;
var jwtExpiry = now + JwtLifetime;
Expand All @@ -350,6 +364,30 @@ private async Task<bool> RefreshOidcTokenAsync(TokenRefreshManager caller, OidcT
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 ee422c6

Please sign in to comment.