Skip to content

Commit eb175d2

Browse files
committed
feat(specs): add forgeSecuredUserToken helper templates and manual tests
- Add agent_studio_helpers.mustache for all 10 non-JS languages - Wire {{#isAgentStudioClient}} blocks in all api.mustache templates - Add manual tests for all 11 languages - Add generation.config.mjs protection for C#, JS __tests__, Scala manual dir
1 parent 84d0339 commit eb175d2

36 files changed

Lines changed: 1031 additions & 1 deletion
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { createHmac } from 'node:crypto';
2+
3+
import { expect, test } from 'vitest';
4+
5+
import { agentStudioClient } from '../builds/node';
6+
7+
test('forgeSecuredUserToken with default expiry', () => {
8+
const client = agentStudioClient('APP_ID', 'API_KEY');
9+
10+
const token = client.forgeSecuredUserToken({
11+
secretKey: 'my-secret-key',
12+
secretKeyId: 'my-key-id',
13+
userId: 'user-123',
14+
});
15+
16+
const parts = token.split('.');
17+
expect(parts).toHaveLength(3);
18+
19+
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
20+
expect(header.alg).toBe('HS256');
21+
expect(header.typ).toBe('JWT');
22+
expect(header.kid).toBe('my-key-id');
23+
24+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
25+
expect(payload.sub).toBe('user-123');
26+
const expectedExp = Math.floor(Date.now() / 1000) + 24 * 3600;
27+
expect(Math.abs(payload.exp - expectedExp)).toBeLessThan(5);
28+
29+
const expectedSig = createHmac('sha256', 'my-secret-key')
30+
.update(`${parts[0]}.${parts[1]}`)
31+
.digest('base64url');
32+
expect(parts[2]).toBe(expectedSig);
33+
});
34+
35+
test('forgeSecuredUserToken with custom expiry', () => {
36+
const client = agentStudioClient('APP_ID', 'API_KEY');
37+
38+
const token = client.forgeSecuredUserToken({
39+
secretKey: 'my-secret-key',
40+
secretKeyId: 'my-key-id',
41+
userId: 'user-456',
42+
expiresIn: 3600,
43+
});
44+
45+
const parts = token.split('.');
46+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
47+
const expectedExp = Math.floor(Date.now() / 1000) + 3600;
48+
expect(Math.abs(payload.exp - expectedExp)).toBeLessThan(5);
49+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.algolia.client
2+
3+
import com.algolia.client.api.AgentStudioClient
4+
import com.algolia.client.extensions.internal.encodeKeySHA256
5+
import kotlin.io.encoding.Base64
6+
import kotlin.io.encoding.ExperimentalEncodingApi
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertTrue
10+
import kotlinx.datetime.Clock
11+
import kotlinx.serialization.json.Json
12+
import kotlinx.serialization.json.JsonObject
13+
import kotlinx.serialization.json.jsonPrimitive
14+
15+
@OptIn(ExperimentalEncodingApi::class)
16+
class TestForgeSecuredUserToken {
17+
18+
@Test
19+
fun forgeSecuredUserToken() {
20+
val client = AgentStudioClient(appId = "appID", apiKey = "apiKey")
21+
22+
val token = client.forgeSecuredUserToken("my-secret-key", "my-key-id", "user-123")
23+
24+
val parts = token.split(".")
25+
assertEquals(3, parts.size)
26+
27+
val headerJson = Base64.UrlSafe.decode(parts[0]).decodeToString()
28+
val header = Json.decodeFromString<JsonObject>(headerJson)
29+
assertEquals("HS256", header["alg"]!!.jsonPrimitive.content)
30+
assertEquals("JWT", header["typ"]!!.jsonPrimitive.content)
31+
assertEquals("my-key-id", header["kid"]!!.jsonPrimitive.content)
32+
33+
val payloadJson = Base64.UrlSafe.decode(parts[1]).decodeToString()
34+
val payload = Json.decodeFromString<JsonObject>(payloadJson)
35+
assertEquals("user-123", payload["sub"]!!.jsonPrimitive.content)
36+
val exp = payload["exp"]!!.jsonPrimitive.content.toLong()
37+
val expectedExp = Clock.System.now().epochSeconds + 24 * 3600
38+
assertTrue(kotlin.math.abs(exp - expectedExp) < 5, "exp $exp should be within 5s of $expectedExp")
39+
40+
val expectedHmacHex = encodeKeySHA256(key = "my-secret-key", message = "${parts[0]}.${parts[1]}")
41+
val actualSigBytes = Base64.UrlSafe.decode(parts[2])
42+
val actualSigHex = actualSigBytes.joinToString("") { "%02x".format(it) }
43+
assertEquals(expectedHmacHex, actualSigHex)
44+
}
45+
46+
@Test
47+
fun forgeSecuredUserTokenCustomExpiry() {
48+
val client = AgentStudioClient(appId = "appID", apiKey = "apiKey")
49+
50+
val token = client.forgeSecuredUserToken("my-secret-key", "my-key-id", "user-456", 3600)
51+
52+
val parts = token.split(".")
53+
val payloadJson = Base64.UrlSafe.decode(parts[1]).decodeToString()
54+
val payload = Json.decodeFromString<JsonObject>(payloadJson)
55+
val exp = payload["exp"]!!.jsonPrimitive.content.toLong()
56+
val expectedExp = Clock.System.now().epochSeconds + 3600
57+
assertTrue(kotlin.math.abs(exp - expectedExp) < 5, "exp $exp should be within 5s of $expectedExp")
58+
}
59+
}

config/generation.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const patterns = [
3131
'!tests/output/csharp/src/TimeoutIntegrationTests.cs',
3232
'!tests/output/csharp/src/Utils/**',
3333
'!tests/output/csharp/src/TransformationOptionsTests.cs',
34+
'!tests/output/csharp/src/ForgeSecuredUserTokenTests.cs',
3435

3536
// Dart
3637
'!clients/algoliasearch-client-dart/**',
@@ -82,6 +83,7 @@ export const patterns = [
8283
'!clients/algoliasearch-client-javascript/packages/client-common/**',
8384
'!clients/algoliasearch-client-javascript/packages/logger-console/**',
8485
'!clients/algoliasearch-client-javascript/packages/algoliasearch/__tests__/**',
86+
'!clients/algoliasearch-client-javascript/packages/agent-studio/__tests__/**',
8587
'!clients/algoliasearch-client-javascript/packages/algoliasearch/vitest.config.ts',
8688

8789
'tests/output/javascript/package.json',
@@ -153,6 +155,8 @@ export const patterns = [
153155
'!clients/algoliasearch-client-scala/src/main/scala/algoliasearch/config/**',
154156
'!clients/algoliasearch-client-scala/src/main/scala/algoliasearch/extension/**',
155157

158+
'!tests/output/scala/src/test/scala/algoliasearch/manual/**',
159+
156160
// Swift
157161
'clients/algoliasearch-client-swift/**',
158162
'!clients/algoliasearch-client-swift/*',
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{{#isAgentStudioClient}}
2+
/// <summary>
3+
/// Helper: Forges a secured user token (JWT) for Agent Studio authenticated requests.
4+
/// </summary>
5+
/// <param name="secretKey">The secret key to sign the token with.</param>
6+
/// <param name="secretKeyId">The key ID to include in the JWT header (kid).</param>
7+
/// <param name="userId">The user identifier to include as the subject (sub) claim.</param>
8+
/// <param name="expiresIn">The token expiration in seconds from now. Defaults to 86400 (24 hours).</param>
9+
/// <returns>The signed JWT token.</returns>
10+
public string ForgeSecuredUserToken(string secretKey, string secretKeyId, string userId, int expiresIn = 86400)
11+
{
12+
static string Base64UrlEncode(byte[] data) =>
13+
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
14+
15+
var header = Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes("{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"" + secretKeyId + "\"}"));
16+
var payload = Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes("{\"sub\":\"" + userId + "\",\"exp\":" + (DateTimeOffset.UtcNow.ToUnixTimeSeconds() + expiresIn) + "}"));
17+
18+
using var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secretKey));
19+
var signature = Base64UrlEncode(hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(header + "." + payload)));
20+
21+
return header + "." + payload + "." + signature;
22+
}
23+
{{/isAgentStudioClient}}

templates/csharp/api.mustache

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,5 +516,9 @@ namespace Algolia.Search.Clients;
516516

517517
{{/supportsAsync}}
518518
{{/operation}}
519+
520+
{{#isAgentStudioClient}}
521+
{{> agent_studio_helpers}}
522+
{{/isAgentStudioClient}}
519523
}
520524
{{/operations}}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/// Helper: Forges a secured user token (JWT) for Agent Studio authenticated requests.
2+
///
3+
/// Parameters:
4+
/// * [secretKey] The secret key to sign the token with.
5+
/// * [secretKeyId] The key ID to include in the JWT header (kid).
6+
/// * [userId] The user identifier to include as the subject (sub) claim.
7+
/// * [expiresIn] The token expiration in seconds from now. Defaults to 86400 (24 hours).
8+
String forgeSecuredUserToken({
9+
required String secretKey,
10+
required String secretKeyId,
11+
required String userId,
12+
int expiresIn = 86400,
13+
}) {
14+
String base64UrlEncode(List<int> data) =>
15+
base64Url.encode(data).replaceAll('=', '');
16+
17+
final header = base64UrlEncode(utf8.encode('{"alg":"HS256","typ":"JWT","kid":"$secretKeyId"}'));
18+
final exp = (DateTime.now().millisecondsSinceEpoch ~/ 1000) + expiresIn;
19+
final payload = base64UrlEncode(utf8.encode('{"sub":"$userId","exp":$exp}'));
20+
21+
final hmac = Hmac(sha256, utf8.encode(secretKey));
22+
final signature = base64UrlEncode(hmac.convert(utf8.encode('$header.$payload')).bytes);
23+
24+
return '$header.$payload.$signature';
25+
}

templates/dart/api.mustache

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import 'package:algolia_client_core/algolia_client_core.dart';
44
import 'package:{{pubName}}/src/deserialize.dart';
55
import 'package:{{pubName}}/src/version.dart';
6+
{{#isAgentStudioClient}}
7+
import 'dart:convert';
8+
import 'package:crypto/crypto.dart';
9+
{{/isAgentStudioClient}}
610

711
{{#operations}}
812
{{#imports}}import '{{{.}}}';
@@ -183,6 +187,10 @@ final class {{classname}} implements ApiClient {
183187
}
184188
{{/operation}}
185189

190+
{{#isAgentStudioClient}}
191+
{{> agent_studio_helpers}}
192+
{{/isAgentStudioClient}}
193+
186194
{{^isCompositionClient}}
187195
@Deprecated('This operation has been deprecated, use `customPost` instead')
188196
Future<Object> post({
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
ForgeSecuredUserToken forges a secured user token (JWT) for Agent Studio authenticated requests.
3+
4+
- secretKey: The secret key to sign the token with.
5+
- secretKeyId: The key ID to include in the JWT header (kid).
6+
- userId: The user identifier to include as the subject (sub) claim.
7+
- opts: Optional request options. Use WithExpiresIn to set a custom expiration (defaults to 24 hours).
8+
9+
@return string - The signed JWT token.
10+
*/
11+
func (c *APIClient) ForgeSecuredUserToken(secretKey string, secretKeyId string, userId string, opts ...RequestOption) (string, error) {
12+
conf := config{}
13+
for _, opt := range opts {
14+
opt.apply(&conf)
15+
}
16+
17+
expiresIn := 24 * 3600 // 24 hours
18+
if conf.expiresIn > 0 {
19+
expiresIn = conf.expiresIn
20+
}
21+
22+
header := base64URLEncode([]byte(fmt.Sprintf(`{"alg":"HS256","typ":"JWT","kid":"%s"}`, secretKeyId)))
23+
payload := base64URLEncode([]byte(fmt.Sprintf(`{"sub":"%s","exp":%d}`, userId, time.Now().Unix()+int64(expiresIn))))
24+
25+
mac := hmac.New(sha256.New, []byte(secretKey))
26+
mac.Write([]byte(header + "." + payload))
27+
signature := base64URLEncode(mac.Sum(nil))
28+
29+
return header + "." + payload + "." + signature, nil
30+
}
31+
32+
func base64URLEncode(data []byte) string {
33+
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
34+
}

templates/go/api.mustache

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import (
2020
"slices"
2121
"sort"
2222
{{/isSearchClient}}
23+
{{#isAgentStudioClient}}
24+
"crypto/hmac"
25+
"crypto/sha256"
26+
"encoding/base64"
27+
{{/isAgentStudioClient}}
2328
"time"
2429

2530
"github.com/algolia/algoliasearch-client-go/v4/algolia/utils"
@@ -64,6 +69,11 @@ type config struct {
6469
// -- WaitForApiKey options
6570
apiKey *ApiKey
6671
{{/isSearchClient}}
72+
73+
{{#isAgentStudioClient}}
74+
// -- ForgeSecuredUserToken options
75+
expiresIn int
76+
{{/isAgentStudioClient}}
6777
}
6878

6979
type RequestOption interface {
@@ -254,6 +264,14 @@ func WithApiKey(apiKey *ApiKey) waitForApiKeyOption {
254264
})
255265
}
256266

267+
{{#isAgentStudioClient}}
268+
func WithExpiresIn(expiresIn int) requestOption {
269+
return requestOption(func(c *config) {
270+
c.expiresIn = expiresIn
271+
})
272+
}
273+
{{/isAgentStudioClient}}
274+
257275
// --------- Helper to convert options ---------
258276

259277
func toRequestOptions[T RequestOption](opts []T) []RequestOption {
@@ -575,4 +593,8 @@ func (c *APIClient) {{nickname}}({{#hasParams}}r {{#structPrefix}}{{&classname}}
575593
{{> helpers}}
576594

577595
{{> ingestion_helpers}}
578-
{{/isIngestionClient}}
596+
{{/isIngestionClient}}
597+
598+
{{#isAgentStudioClient}}
599+
{{> agent_studio_helpers}}
600+
{{/isAgentStudioClient}}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{{#isAgentStudioClient}}
2+
/**
3+
* Helper: Forges a secured user token (JWT) for Agent Studio authenticated requests.
4+
*
5+
* @param secretKey The secret key to sign the token with.
6+
* @param secretKeyId The key ID to include in the JWT header (kid).
7+
* @param userId The user identifier to include as the subject (sub) claim.
8+
* @param expiresIn The token expiration in seconds from now. Defaults to 86400 (24 hours).
9+
* @return The signed JWT token.
10+
*/
11+
public String forgeSecuredUserToken(String secretKey, String secretKeyId, String userId, int expiresIn) {
12+
try {
13+
String header = base64UrlEncode(String.format("{\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"%s\"}", secretKeyId).getBytes(java.nio.charset.StandardCharsets.UTF_8));
14+
String payload = base64UrlEncode(String.format("{\"sub\":\"%s\",\"exp\":%d}", userId, Instant.now().getEpochSecond() + expiresIn).getBytes(java.nio.charset.StandardCharsets.UTF_8));
15+
16+
Mac mac = Mac.getInstance("HmacSHA256");
17+
mac.init(new SecretKeySpec(secretKey.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256"));
18+
String signature = base64UrlEncode(mac.doFinal((header + "." + payload).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
19+
20+
return header + "." + payload + "." + signature;
21+
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
22+
throw new AlgoliaRuntimeException("Failed to forge secured user token", e);
23+
}
24+
}
25+
26+
public String forgeSecuredUserToken(String secretKey, String secretKeyId, String userId) {
27+
return forgeSecuredUserToken(secretKey, secretKeyId, userId, 24 * 3600);
28+
}
29+
30+
private static String base64UrlEncode(byte[] data) {
31+
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
32+
}
33+
{{/isAgentStudioClient}}

0 commit comments

Comments
 (0)