Skip to content

Commit 4a02bb7

Browse files
committed
feat: Support programmatic refresh external credentials.
Closes googleapis#2951
1 parent e568f6e commit 4a02bb7

5 files changed

Lines changed: 413 additions & 8 deletions

File tree

Src/Support/Google.Apis.Auth.Tests/OAuth2/GoogleCredentialTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ private IConfiguration BuildConfigurationForCredential(string json) =>
143143
// this library.
144144
new ConfigurationBuilder().AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(JToken.Parse(json).ToString()))).Build();
145145

146+
[Fact]
147+
public void FromProgrammaticExternalAccountCredential()
148+
{
149+
var fakeSubjectTokenProvider = new ProgrammaticExternalAccountCredentialTest.FakeSubjectTokenProvider();
150+
var credential = new ProgrammaticExternalAccountCredential(new ProgrammaticExternalAccountCredential.Initializer(
151+
"STS_TOKEN_URL", "AUDIENCE", "SUBJECT_TOKEN_TYPE", fakeSubjectTokenProvider));
152+
153+
var gCredential = GoogleCredential.FromProgrammaticExternalAccountCredential(credential);
154+
155+
var pCred = Assert.IsType<ProgrammaticExternalAccountCredential>(gCredential.UnderlyingCredential);
156+
Assert.Equal("STS_TOKEN_URL", pCred.TokenServerUrl);
157+
Assert.Equal("AUDIENCE", pCred.Audience);
158+
Assert.Equal("SUBJECT_TOKEN_TYPE", pCred.SubjectTokenType);
159+
Assert.Same(fakeSubjectTokenProvider, pCred.SubjectTokenProvider);
160+
}
161+
146162
[Fact]
147163
public void FromConfig_UserCredential()
148164
{
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/*
2+
Copyright 2025 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
using Google.Apis.Auth.OAuth2;
18+
using Google.Apis.Tests.Mocks;
19+
using System;
20+
using System.Threading;
21+
using System.Threading.Tasks;
22+
using Xunit;
23+
24+
namespace Google.Apis.Auth.Tests.OAuth2;
25+
26+
public class ProgrammaticExternalAccountCredentialTest : ExternalAccountCredentialTestsBase
27+
{
28+
public class FakeSubjectTokenProvider : ProgrammaticExternalAccountCredential.ISubjectTokenProvider
29+
{
30+
31+
private readonly string _subjectToken;
32+
33+
public ProgrammaticExternalAccountCredential LatestCaller { get; private set; }
34+
35+
private int _callCount = 0;
36+
public int CallCount => _callCount;
37+
38+
public FakeSubjectTokenProvider(string subjectToken = FakeSubjectTokenText) => _subjectToken = subjectToken;
39+
40+
public Task<string> GetSubjectTokenAsync(ProgrammaticExternalAccountCredential caller, CancellationToken taskCancellationToken)
41+
{
42+
LatestCaller = caller;
43+
Interlocked.Increment(ref _callCount);
44+
return Task.FromResult(_subjectToken);
45+
}
46+
}
47+
48+
[Fact]
49+
public async Task UniverseDomain_Default()
50+
{
51+
var credential = new ProgrammaticExternalAccountCredential(new ProgrammaticExternalAccountCredential.Initializer(
52+
TokenUrl, FakeAudience, FakeSubjectTokenType, new FakeSubjectTokenProvider())) as IGoogleCredential;
53+
54+
Assert.Equal(GoogleAuthConsts.DefaultUniverseDomain, credential.GetUniverseDomain());
55+
Assert.Equal(GoogleAuthConsts.DefaultUniverseDomain, await credential.GetUniverseDomainAsync(default));
56+
}
57+
58+
[Fact]
59+
public async Task UniverseDomain_Custom()
60+
{
61+
var credential = new ProgrammaticExternalAccountCredential(new ProgrammaticExternalAccountCredential.Initializer(
62+
TokenUrl, FakeAudience, FakeSubjectTokenType, new FakeSubjectTokenProvider())
63+
{
64+
UniverseDomain = FakeUniverseDomain
65+
}) as IGoogleCredential;
66+
67+
Assert.Equal(FakeUniverseDomain, credential.GetUniverseDomain());
68+
Assert.Equal(FakeUniverseDomain, await credential.GetUniverseDomainAsync(default));
69+
}
70+
71+
[Fact]
72+
public async Task WithUniverseDomain()
73+
{
74+
var credential = new ProgrammaticExternalAccountCredential(new ProgrammaticExternalAccountCredential.Initializer(
75+
TokenUrl, FakeAudience, FakeSubjectTokenType, new FakeSubjectTokenProvider())) as IGoogleCredential;
76+
77+
var newCredential = credential.WithUniverseDomain(FakeUniverseDomain);
78+
79+
Assert.NotSame(credential, newCredential);
80+
Assert.IsType<ProgrammaticExternalAccountCredential>(newCredential);
81+
82+
Assert.Equal(GoogleAuthConsts.DefaultUniverseDomain, credential.GetUniverseDomain());
83+
Assert.Equal(GoogleAuthConsts.DefaultUniverseDomain, await credential.GetUniverseDomainAsync(default));
84+
85+
Assert.Equal(FakeUniverseDomain, newCredential.GetUniverseDomain());
86+
Assert.Equal(FakeUniverseDomain, await newCredential.GetUniverseDomainAsync(default));
87+
}
88+
89+
[Fact]
90+
public async Task FetchesAccessToken()
91+
{
92+
var messageHandler = new DelegatedMessageHandler(request => ValidateAccessTokenRequest(request, FakeScope));
93+
94+
var credential = new ProgrammaticExternalAccountCredential(
95+
new ProgrammaticExternalAccountCredential.Initializer(TokenUrl, FakeAudience, FakeSubjectTokenType, new FakeSubjectTokenProvider())
96+
{
97+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
98+
ClientId = FakeClientId,
99+
ClientSecret = FakeClientSecret,
100+
Scopes = new string[] { FakeScope },
101+
QuotaProject = FakeQuotaProject
102+
});
103+
104+
var token = await credential.GetAccessTokenWithHeadersForRequestAsync();
105+
106+
AssertAccessTokenWithHeaders(token);
107+
messageHandler.AssertAllCallsMade();
108+
}
109+
110+
[Fact]
111+
public async Task FetchesAccessToken_Caller()
112+
{
113+
var subjectTokenProvider = new FakeSubjectTokenProvider();
114+
var messageHandler = new DelegatedMessageHandler(request => ValidateAccessTokenRequest(request, FakeScope));
115+
116+
var credential = new ProgrammaticExternalAccountCredential(
117+
new ProgrammaticExternalAccountCredential.Initializer(TokenUrl, FakeAudience, FakeSubjectTokenType, subjectTokenProvider)
118+
{
119+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
120+
ClientId = FakeClientId,
121+
ClientSecret = FakeClientSecret,
122+
Scopes = new string[] { FakeScope }
123+
});
124+
125+
await credential.GetAccessTokenForRequestAsync();
126+
127+
Assert.Same(credential, subjectTokenProvider.LatestCaller);
128+
messageHandler.AssertAllCallsMade();
129+
}
130+
131+
[Fact]
132+
public async Task FetchesAccessToken_Impersonated()
133+
{
134+
var messageHandler = new DelegatedMessageHandler(
135+
request => ValidateAccessTokenRequest(request, ImpersonationScope),
136+
ValidateImpersonatedAccessTokenRequest);
137+
138+
var credential = new ProgrammaticExternalAccountCredential(
139+
new ProgrammaticExternalAccountCredential.Initializer(TokenUrl, FakeAudience, FakeSubjectTokenType, new FakeSubjectTokenProvider())
140+
{
141+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
142+
ClientId = FakeClientId,
143+
ClientSecret = FakeClientSecret,
144+
Scopes = new string[] { FakeScope },
145+
QuotaProject = FakeQuotaProject,
146+
ServiceAccountImpersonationUrl = ImpersonationUrl
147+
});
148+
149+
var token = await credential.GetAccessTokenWithHeadersForRequestAsync();
150+
151+
AssertImpersonatedAccessTokenWithHeaders(token);
152+
messageHandler.AssertAllCallsMade();
153+
}
154+
155+
[Fact]
156+
public async Task FetchesAccessToken_Workforce()
157+
{
158+
var messageHandler = new DelegatedMessageHandler(request => ValidateAccessTokenRequest(request, FakeScope, isWorkforce: true));
159+
160+
var credential = new ProgrammaticExternalAccountCredential(
161+
new ProgrammaticExternalAccountCredential.Initializer(TokenUrl, FakeAudience, FakeSubjectTokenType, new FakeSubjectTokenProvider())
162+
{
163+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
164+
WorkforcePoolUserProject = FakeWorkforcePoolUserProject,
165+
Scopes = new string[] { FakeScope },
166+
QuotaProject = FakeQuotaProject
167+
});
168+
169+
var token = await credential.GetAccessTokenWithHeadersForRequestAsync();
170+
171+
AssertAccessTokenWithHeaders(token);
172+
messageHandler.AssertAllCallsMade();
173+
}
174+
175+
[Fact]
176+
public async Task FetchesAccessToken_ClientIdAndSecret_IgnoresWorkforce()
177+
{
178+
var messageHandler = new DelegatedMessageHandler(request => ValidateAccessTokenRequest(request, FakeScope, isWorkforce: false));
179+
180+
var credential = new ProgrammaticExternalAccountCredential(
181+
new ProgrammaticExternalAccountCredential.Initializer(TokenUrl, FakeAudience, FakeSubjectTokenType, new FakeSubjectTokenProvider())
182+
{
183+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
184+
WorkforcePoolUserProject = FakeWorkforcePoolUserProject,
185+
ClientId = FakeClientId,
186+
ClientSecret = FakeClientSecret,
187+
Scopes = new string[] { FakeScope },
188+
QuotaProject = FakeQuotaProject
189+
});
190+
191+
var token = await credential.GetAccessTokenWithHeadersForRequestAsync();
192+
193+
AssertAccessTokenWithHeaders(token);
194+
messageHandler.AssertAllCallsMade();
195+
}
196+
197+
[Fact]
198+
public async Task RefreshesAccessToken()
199+
{
200+
var fakeSubjectTokenProvider = new FakeSubjectTokenProvider();
201+
var messageHandler = new DelegatedMessageHandler(AccessTokenRequest, RefreshTokenRequest);
202+
var clock = new MockClock(DateTime.UtcNow);
203+
204+
var credential = new ProgrammaticExternalAccountCredential(
205+
new ProgrammaticExternalAccountCredential.Initializer(TokenUrl, FakeAudience, FakeSubjectTokenType, fakeSubjectTokenProvider)
206+
{
207+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
208+
Clock = clock
209+
});
210+
211+
Assert.Equal(FakeAccessToken, await credential.GetAccessTokenForRequestAsync());
212+
213+
clock.UtcNow = clock.UtcNow.AddDays(2);
214+
215+
Assert.Equal(FakeRefreshedAccessToken, await credential.GetAccessTokenForRequestAsync());
216+
217+
Assert.Equal(2, fakeSubjectTokenProvider.CallCount);
218+
messageHandler.AssertAllCallsMade();
219+
}
220+
}

Src/Support/Google.Apis.Auth/OAuth2/ExternalAccountCredential.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,26 @@ public abstract class ExternalAccountCredential : ServiceCredential
4646
/// <summary>
4747
/// Initializer for <see cref="ExternalAccountCredential"/>.
4848
/// </summary>
49-
new internal abstract class Initializer : ServiceCredential.Initializer
49+
new public class Initializer : ServiceCredential.Initializer
5050
{
5151
/// <summary>
5252
/// The STS audience which contains the resource name for the
5353
/// workload identity pool or the workforce pool
5454
/// and the provider identifier in that pool.
5555
/// </summary>
56-
internal string Audience { get; }
56+
public string Audience { get; }
5757

5858
/// <summary>
5959
/// The STS subject token type based on the OAuth 2.0 token exchange spec.
6060
/// </summary>
61-
internal string SubjectTokenType { get; }
61+
public string SubjectTokenType { get; }
6262

6363
/// <summary>
6464
/// This is the URL for the service account impersonation request.
6565
/// If this is not set, the STS-returned access token
6666
/// should be directly used without impersonation.
6767
/// </summary>
68-
internal string ServiceAccountImpersonationUrl { get; set; }
68+
public string ServiceAccountImpersonationUrl { get; set; }
6969

7070
/// <summary>
7171
/// The GCP project number to be used for Workforce Identity Pools
@@ -76,7 +76,7 @@ public abstract class ExternalAccountCredential : ServiceCredential
7676
/// enabled identity and this values is not specified, then an API key needs to be
7777
/// used alongside this credential to call Google APIs.
7878
/// </remarks>
79-
internal string WorkforcePoolUserProject { get; set; }
79+
public string WorkforcePoolUserProject { get; set; }
8080

8181
/// <summary>
8282
/// The Client ID.
@@ -87,7 +87,7 @@ public abstract class ExternalAccountCredential : ServiceCredential
8787
/// When provided, STS will be called with additional basic authentication using
8888
/// ClientId as username and ClientSecret as password.
8989
/// </remarks>
90-
internal string ClientId { get; set; }
90+
public string ClientId { get; set; }
9191

9292
/// <summary>
9393
/// The client secret.
@@ -98,13 +98,13 @@ public abstract class ExternalAccountCredential : ServiceCredential
9898
/// When provided, STS will be called with additional basic authentication using
9999
/// ClientId as username and ClientSecret as password.
100100
/// </remarks>
101-
internal string ClientSecret { get; set; }
101+
public string ClientSecret { get; set; }
102102

103103
/// <summary>
104104
/// The universe domain this credential belongs to.
105105
/// May be null, in which case the default universe domain will be used.
106106
/// </summary>
107-
internal string UniverseDomain { get; set; }
107+
public string UniverseDomain { get; set; }
108108

109109
internal Initializer(string tokenUrl, string audience, string subjectTokenType) : base(tokenUrl)
110110
{

Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ limitations under the License.
1515
*/
1616

1717
using Google.Apis.Http;
18+
using Google.Apis.Util;
1819
using System;
1920
using System.Collections.Generic;
2021
using System.IO;
@@ -246,6 +247,16 @@ public static GoogleCredential FromAccessToken(string accessToken, IAccessMethod
246247
public static GoogleCredential FromComputeCredential(ComputeCredential computeCredential = null) =>
247248
new GoogleCredential(computeCredential ?? new ComputeCredential());
248249

250+
/// <summary>
251+
/// Create a <see cref="GoogleCredential"/> from a <see cref="ProgrammaticExternalAccountCredential"/>.
252+
/// </summary>
253+
/// <param name="credential">
254+
/// The credential to use in the returned <see cref="GoogleCredential"/>.
255+
/// Must not be null.
256+
/// </param>
257+
public static GoogleCredential FromProgrammaticExternalAccountCredential(ProgrammaticExternalAccountCredential credential) =>
258+
new GoogleCredential(credential.ThrowIfNull(nameof(credential)));
259+
249260
/// <summary>
250261
/// <para>
251262
/// Returns <c>true</c> only if this credential supports explicit scopes to be set

0 commit comments

Comments
 (0)