Skip to content

Commit d7b4f55

Browse files
committed
Add custom GCP ID token generation logic that is compatible with AOT
1 parent 7c6d0ba commit d7b4f55

File tree

5 files changed

+137
-19
lines changed

5 files changed

+137
-19
lines changed

Directory.Packages.props

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
3838
<PackageVersion Include="Errata" Version="0.14.0" />
3939
<PackageVersion Include="Github.Actions.Core" Version="9.0.0" />
40-
<PackageVersion Include="Google.Apis.Auth" Version="1.68.0" />
4140
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.4" />
4241
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.4" />
4342
<PackageVersion Include="Markdig" Version="0.41.1" />
@@ -71,4 +70,4 @@
7170
</PackageVersion>
7271
<PackageVersion Include="xunit.v3" Version="2.0.2" />
7372
</ItemGroup>
74-
</Project>
73+
</Project>

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAiAnswer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ export const AskAiAnswer = () => {
136136
)}
137137
{!error && isLoading && (
138138
<>
139-
{messages.length > 0 && <EuiSpacer size="s" />}
139+
{messages.filter(
140+
(m) =>
141+
m.type === 'ai_message' ||
142+
m.type === 'ai_message_chunk'
143+
).length > 0 && <EuiSpacer size="s" />}
140144
<EuiFlexGroup
141145
alignItems="center"
142146
gutterSize="xs"

src/tooling/docs-builder/Http/DocumentationWebHost.cs

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using Elastic.Markdown.Exporters;
1616
using Elastic.Markdown.IO;
1717
using Elastic.Markdown.Myst.Renderers;
18-
using Google.Apis.Auth.OAuth2;
1918
using Markdig.Syntax;
2019
using Microsoft.AspNetCore.Builder;
2120
using Microsoft.AspNetCore.Hosting;
@@ -286,9 +285,6 @@ private static async Task<IResult> ProxyChatRequest(HttpContext context, Cancell
286285
return Results.Empty;
287286
}
288287

289-
var credential = GoogleCredential.FromFile(serviceAccountKeyPath)
290-
.CreateScoped("https://www.googleapis.com/auth/cloud-platform");
291-
292288
// Get GCP function URL
293289
var gcpFunctionUrl = Environment.GetEnvironmentVariable("GCP_CHAT_FUNCTION_URL");
294290
if (string.IsNullOrEmpty(gcpFunctionUrl))
@@ -302,17 +298,8 @@ private static async Task<IResult> ProxyChatRequest(HttpContext context, Cancell
302298
var functionUri = new Uri(gcpFunctionUrl);
303299
var audienceUrl = $"{functionUri.Scheme}://{functionUri.Host}";
304300

305-
// Generate ID token for GCP Cloud Function authentication
306-
if (credential.UnderlyingCredential is not IOidcTokenProvider oidcProvider)
307-
{
308-
context.Response.StatusCode = 500;
309-
await context.Response.WriteAsync("GCP credential does not support ID tokens", cancellationToken: ctx);
310-
return Results.Empty;
311-
}
312-
313-
var oidcTokenOptions = OidcTokenOptions.FromTargetAudience(audienceUrl);
314-
var oidcTokenSource = await oidcProvider.GetOidcTokenAsync(oidcTokenOptions, ctx);
315-
var idToken = await oidcTokenSource.GetAccessTokenAsync(ctx);
301+
// Generate ID token using AOT-compatible approach
302+
var idToken = await GcpIdTokenGenerator.GenerateIdTokenAsync(serviceAccountKeyPath, audienceUrl, ctx);
316303

317304
// Make request to GCP function
318305
using var httpClient = new HttpClient();
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Security.Cryptography;
6+
using System.Text;
7+
using System.Text.Json;
8+
using System.Text.Json.Serialization;
9+
10+
namespace Documentation.Builder.Http;
11+
12+
internal readonly record struct ServiceAccountKey(
13+
string Type,
14+
string ProjectId,
15+
string PrivateKeyId,
16+
string PrivateKey,
17+
string ClientEmail,
18+
string ClientId,
19+
string AuthUri,
20+
string TokenUri,
21+
string AuthProviderX509CertUrl,
22+
string ClientX509CertUrl
23+
);
24+
25+
internal readonly record struct JwtHeader(string Alg, string Typ, string Kid);
26+
27+
internal readonly record struct JwtPayload(
28+
string Iss,
29+
string Sub,
30+
string Aud,
31+
long Iat,
32+
long Exp,
33+
string TargetAudience
34+
);
35+
36+
[JsonSerializable(typeof(ServiceAccountKey))]
37+
[JsonSerializable(typeof(JwtPayload))]
38+
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)]
39+
internal sealed partial class GcpJsonContext : JsonSerializerContext { }
40+
41+
[JsonSerializable(typeof(JwtHeader))]
42+
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
43+
internal sealed partial class JwtHeaderJsonContext : JsonSerializerContext { }
44+
45+
// This is a custom implementation to create an ID token for GCP.
46+
// Because Google.Api.Auth.OAuth2 is not compatible with AOT
47+
public static class GcpIdTokenGenerator
48+
{
49+
50+
public static async Task<string> GenerateIdTokenAsync(string serviceAccountKeyPath, string targetAudience, CancellationToken cancellationToken = default)
51+
{
52+
// Read and parse service account key file using System.Text.Json source generation (AOT compatible)
53+
var serviceAccountJson = await File.ReadAllTextAsync(serviceAccountKeyPath, cancellationToken);
54+
var serviceAccount = JsonSerializer.Deserialize(serviceAccountJson, GcpJsonContext.Default.ServiceAccountKey);
55+
56+
// Create JWT header
57+
var header = new JwtHeader("RS256", "JWT", serviceAccount.PrivateKeyId);
58+
var headerJson = JsonSerializer.Serialize(header, JwtHeaderJsonContext.Default.JwtHeader);
59+
var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
60+
61+
// Create JWT payload
62+
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
63+
var payload = new JwtPayload(
64+
serviceAccount.ClientEmail,
65+
serviceAccount.ClientEmail,
66+
"https://oauth2.googleapis.com/token",
67+
now,
68+
now + 3600, // 1 hour expiration
69+
targetAudience
70+
);
71+
72+
var payloadJson = JsonSerializer.Serialize(payload, GcpJsonContext.Default.JwtPayload);
73+
var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
74+
75+
// Create signature
76+
var message = $"{headerBase64}.{payloadBase64}";
77+
var messageBytes = Encoding.UTF8.GetBytes(message);
78+
79+
// Parse the private key (removing PEM headers/footers and decoding)
80+
var privateKeyPem = serviceAccount.PrivateKey
81+
.Replace("-----BEGIN PRIVATE KEY-----", "")
82+
.Replace("-----END PRIVATE KEY-----", "")
83+
.Replace("\n", "")
84+
.Replace("\r", "");
85+
var privateKeyBytes = Convert.FromBase64String(privateKeyPem);
86+
87+
// Create RSA instance and sign
88+
using var rsa = RSA.Create();
89+
rsa.ImportPkcs8PrivateKey(privateKeyBytes, out _);
90+
var signature = rsa.SignData(messageBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
91+
var signatureBase64 = Base64UrlEncode(signature);
92+
93+
var jwt = $"{message}.{signatureBase64}";
94+
95+
// Exchange JWT for ID token
96+
return await ExchangeJwtForIdToken(jwt, targetAudience, cancellationToken);
97+
}
98+
99+
private static async Task<string> ExchangeJwtForIdToken(string jwt, string targetAudience, CancellationToken cancellationToken)
100+
{
101+
using var httpClient = new HttpClient();
102+
103+
var requestContent = new FormUrlEncodedContent([
104+
new KeyValuePair<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
105+
new KeyValuePair<string, string>("assertion", jwt),
106+
new KeyValuePair<string, string>("target_audience", targetAudience)
107+
]);
108+
109+
var response = await httpClient.PostAsync("https://oauth2.googleapis.com/token", requestContent, cancellationToken);
110+
_ = response.EnsureSuccessStatusCode();
111+
112+
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
113+
using var document = JsonDocument.Parse(responseJson);
114+
115+
if (document.RootElement.TryGetProperty("id_token", out var idTokenElement))
116+
{
117+
return idTokenElement.GetString() ?? throw new InvalidOperationException("ID token is null");
118+
}
119+
120+
throw new InvalidOperationException("No id_token found in response");
121+
}
122+
123+
private static string Base64UrlEncode(byte[] input)
124+
{
125+
var base64 = Convert.ToBase64String(input);
126+
// Convert base64 to base64url encoding
127+
return base64.Replace('+', '-').Replace('/', '_').TrimEnd('=');
128+
}
129+
}

src/tooling/docs-builder/docs-builder.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
<ItemGroup>
2222
<PackageReference Include="ConsoleAppFramework.Abstractions" />
2323
<PackageReference Include="ConsoleAppFramework" />
24-
<PackageReference Include="Google.Apis.Auth" />
2524
<PackageReference Include="Westwind.AspNetCore.LiveReload" />
2625
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
2726
</ItemGroup>

0 commit comments

Comments
 (0)