Skip to content

Commit 7303103

Browse files
authored
feat: support client assertion for client credentials authentication (#228)
* feat(credentials): support using client assertion in client credentials auth * refactor: rename type to privatekeyjwt instead of clientassertion
1 parent b605f86 commit 7303103

File tree

7 files changed

+185
-34
lines changed

7 files changed

+185
-34
lines changed

configuration.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,7 @@ export class Configuration {
152152
case CredentialsMethod.ClientCredentials:
153153
this.credentials = {
154154
method: CredentialsMethod.ClientCredentials,
155-
config: {
156-
// We are only copying them from the passed in params here. We will be validating that they are valid in the Credentials constructor
157-
clientId: credentialParams.config.clientId,
158-
clientSecret: credentialParams.config.clientSecret,
159-
apiAudience: credentialParams.config.apiAudience,
160-
apiTokenIssuer: credentialParams.config.apiTokenIssuer,
161-
}
155+
config: credentialParams.config
162156
};
163157
break;
164158
case CredentialsMethod.None:

credentials/credentials.ts

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,30 @@
1212

1313

1414
import globalAxios, { AxiosInstance } from "axios";
15+
import * as jose from "jose";
1516

1617
import { assertParamExists, isWellFormedUriString } from "../validation";
1718
import { FgaApiAuthenticationError, FgaApiError, FgaValidationError } from "../errors";
1819
import { attemptHttpRequest } from "../common";
19-
import { AuthCredentialsConfig, ClientCredentialsConfig, CredentialsMethod } from "./types";
20+
import { AuthCredentialsConfig, PrivateKeyJWTConfig, ClientCredentialsConfig, ClientSecretConfig, CredentialsMethod } from "./types";
2021
import { TelemetryAttributes } from "../telemetry/attributes";
2122
import { TelemetryCounters } from "../telemetry/counters";
2223
import { TelemetryConfiguration } from "../telemetry/configuration";
24+
import { randomUUID } from "crypto";
25+
26+
interface ClientSecretRequest {
27+
client_id: string;
28+
client_secret: string;
29+
audience: string;
30+
grant_type: "client_credentials";
31+
}
32+
33+
interface ClientAssertionRequest {
34+
client_id: string;
35+
client_assertion: string;
36+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
37+
audience: string;
38+
}
2339

2440
export class Credentials {
2541
private accessToken?: string;
@@ -73,10 +89,11 @@ export class Credentials {
7389
break;
7490
case CredentialsMethod.ClientCredentials:
7591
assertParamExists("Credentials", "config.clientId", authConfig.config?.clientId);
76-
assertParamExists("Credentials", "config.clientSecret", authConfig.config?.clientSecret);
7792
assertParamExists("Credentials", "config.apiTokenIssuer", authConfig.config?.apiTokenIssuer);
7893
assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience);
7994

95+
assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey);
96+
8097
if (!isWellFormedUriString(`https://${authConfig.config?.apiTokenIssuer}`)) {
8198
throw new FgaValidationError(
8299
`Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`);
@@ -129,25 +146,16 @@ export class Credentials {
129146
private async refreshAccessToken() {
130147
const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config;
131148
const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`;
149+
const credentialsPayload = await this.buildClientAuthenticationPayload();
132150

133151
try {
134-
const wrappedResponse = await attemptHttpRequest<{
135-
client_id: string,
136-
client_secret: string,
137-
audience: string,
138-
grant_type: "client_credentials",
139-
}, {
152+
const wrappedResponse = await attemptHttpRequest<ClientSecretRequest|ClientAssertionRequest, {
140153
access_token: string,
141154
expires_in: number,
142155
}>({
143156
url,
144157
method: "POST",
145-
data: {
146-
client_id: clientCredentials.clientId,
147-
client_secret: clientCredentials.clientSecret,
148-
audience: clientCredentials.apiAudience,
149-
grant_type: "client_credentials",
150-
},
158+
data: credentialsPayload,
151159
headers: {
152160
"Content-Type": "application/x-www-form-urlencoded"
153161
}
@@ -199,4 +207,43 @@ export class Credentials {
199207
throw err;
200208
}
201209
}
210+
211+
private async buildClientAuthenticationPayload(): Promise<ClientSecretRequest|ClientAssertionRequest> {
212+
if (this.authConfig?.method !== CredentialsMethod.ClientCredentials) {
213+
throw new FgaValidationError("Credentials method is not set to ClientCredentials");
214+
}
215+
216+
const config = this.authConfig.config;
217+
if ((config as PrivateKeyJWTConfig).clientAssertionSigningKey) {
218+
const alg = (config as PrivateKeyJWTConfig).clientAssertionSigningAlgorithm || "RS256";
219+
const privateKey = await jose.importPKCS8((config as PrivateKeyJWTConfig).clientAssertionSigningKey, alg);
220+
const assertion = await new jose.SignJWT({})
221+
.setProtectedHeader({ alg })
222+
.setIssuedAt()
223+
.setSubject(config.clientId)
224+
.setJti(randomUUID())
225+
.setIssuer(config.clientId)
226+
.setAudience(`https://${config.apiTokenIssuer}/`)
227+
.setExpirationTime("2m")
228+
.sign(privateKey);
229+
return {
230+
...config.customClaims,
231+
client_id: (config as PrivateKeyJWTConfig).clientId,
232+
client_assertion: assertion,
233+
audience: config.apiAudience,
234+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
235+
grant_type: "client_credentials",
236+
} as ClientAssertionRequest;
237+
} else if ((config as ClientSecretConfig).clientSecret) {
238+
return {
239+
...config.customClaims,
240+
client_id: (config as ClientSecretConfig).clientId,
241+
client_secret: (config as ClientSecretConfig).clientSecret,
242+
audience: (config as ClientSecretConfig).apiAudience,
243+
grant_type: "client_credentials",
244+
};
245+
}
246+
247+
throw new FgaValidationError("Credentials method is set to ClientCredentials, but no clientSecret or clientAssertionSigningKey is provided");
248+
}
202249
}

credentials/types.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,63 @@ export enum CredentialsMethod {
1717
ClientCredentials = "client_credentials",
1818
}
1919

20-
export interface ClientCredentialsConfig {
20+
type BaseClientCredentialsConfig = {
2121
/**
2222
* Client ID
2323
*
2424
* @type {string}
2525
* @memberof Configuration
2626
*/
2727
clientId: string;
28+
/**
29+
* API Token Issuer
30+
*
31+
* @type {string}
32+
*/
33+
apiTokenIssuer: string;
34+
/**
35+
* API Audience
36+
*
37+
* @type {string}
38+
*/
39+
apiAudience: string;
40+
/**
41+
* Claims to be included in the token exchange request.
42+
*
43+
* @type {Record<string, string>}
44+
*/
45+
customClaims?: Record<string, string>
46+
}
47+
48+
export type ClientSecretConfig = BaseClientCredentialsConfig & {
2849
/**
2950
* Client Secret
3051
*
3152
* @type {string}
3253
* @memberof Configuration
3354
*/
3455
clientSecret: string;
56+
57+
}
58+
export type PrivateKeyJWTConfig = BaseClientCredentialsConfig & {
3559
/**
36-
* API Token Issuer
60+
* Client assertion signing key
3761
*
3862
* @type {string}
63+
* @memberof Configuration
3964
*/
40-
apiTokenIssuer: string;
65+
clientAssertionSigningKey: string;
4166
/**
42-
* API Audience
43-
*
67+
* Client assertion signing algorithm,
68+
* defaults to `RS256` if not specified.
4469
* @type {string}
70+
* @memberof Configuration
4571
*/
46-
apiAudience: string;
72+
clientAssertionSigningAlgorithm?: string;
4773
}
4874

75+
export type ClientCredentialsConfig = ClientSecretConfig | PrivateKeyJWTConfig;
76+
4977
export interface ApiTokenConfig {
5078
/**
5179
* API Token Value

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"dependencies": {
2525
"@opentelemetry/api": "^1.9.0",
2626
"axios": "^1.8.3",
27+
"jose": "^5.10.0",
2728
"tiny-async-pool": "^2.1.0"
2829
},
2930
"devDependencies": {

tests/helpers/default-config.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,34 @@ export const OPENFGA_API_AUDIENCE = "https://api.fga.example/";
2222
export const OPENFGA_CLIENT_ID = "01H0H3D8TD07EWAQHXY9BWJG3V";
2323
export const OPENFGA_CLIENT_SECRET = "this-is-very-secret";
2424
export const OPENFGA_API_TOKEN = "fga_abcdef";
25+
export const OPENFGA_CLIENT_ASSERTION_SIGNING_KEY = `-----BEGIN PRIVATE KEY-----
26+
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmJ37Zi9/LS5/I
27+
E5pl7XobscHSFNrTfZC9Jx15KF5iJLFb9s8twQdo/hWPC4adidu7gVCIGNGYBGH2
28+
Q2z9nMrVA5TUQrrTsvJw0ldSWn2MeadZcMGI0AcaomOu8P7lxyaf/sgWAOgW1P+Y
29+
SAEPHHvKuA0orQVVWYwt7jaaQ0GBwEh3XiwqiwUKCJQ06eeQVxXxGr9DBYtZJOzn
30+
gLRj0wNF3WWU5JddV2o+CHRvpN1zLBHam3RXJQMdObs2waeR85AbfO6rNQr/Zscd
31+
Y6XDsHjeAHOykfoMBBexK0Rdu3Vqk2DSaXG3HUC54sbCLZSDmo4S0Dsax1IEWnWs
32+
rA8nD5O7AgMBAAECggEAWZzoNbFSJnhgEsmbNPO1t0HLq05Gc9FwwU2RGsMemM0b
33+
p6ieO3zsszM3VraQqBds0IHFxvAO78dJE1dmgQsDKNSXptwCnXoQDuC/ckfcmY0m
34+
nVsbZ/dDxNmUwaGBRht4TRSpeHPK6lTt3i+vBeC7zI9ERGG18WkH/TxC02a7g1aL
35+
emz/SNgOdFkHPoKcgYyUp2Svh0aly9g2NbyIusNO4C9M/tCYRobcrZBRIognNZKY
36+
bZVQrnilOClVcbND1oOPs0O6sxTMGd3eR7bS6w7i59vUCPwQSTo1L/FA23ZPY5kQ
37+
AgeGZnp4Nve1Ecsvp48MJHb4cwJeysxH6hhyl3zMHQKBgQDzKmo1Sa5wAqTD4HAP
38+
/aboYfSxhIE9c3qhj5HDP76PBZa8N5fyNyTJCsauk2a2/ZFOwCz142yF1JEbxHgb
39+
j6XYYazVFfk2DFWb9Ul/zQMmCVcETlRhxIQPc76f9QjvAc20B6xeR3M14RwfK/u+
40+
FaN7PsMAItH0xJRpGIWpwN/3PQKBgQDyTUY2WsGNUzMKarLyKX5KSDELfgmJtcAv
41+
LunqhYnhks4i6PVknXIY4GuGhIhAanDFlFZIhTa5a2e2bNZvgRz+VxNNRsQQZPgt
42+
M9Gg1fLSqQOL7OZn+cjkkYfxNE1FLMoStaANl6JkCjN4Ted2pLbswCBXwa4qsxRZ
43+
bsA3BTWmVwKBgQCgqYSVAsLLZSPB+7dvCVPPNHF9HKRbmsIKnxZa3/IjAzlN0JmH
44+
QuH+Jy2QyPlTrIPmeVj7ebEJV6Isq4oEA8w7BIYyIBuRl2K08cMHOsh6yC8DPFHK
45+
axIqN3paq4akjBeCfJNpk2HO1pZDDkd9l0R1uMkUfO0mAQBh0/70YuhXrQKBgEbn
46+
igZZ5I3grOz9cEQhFE3UdlWwmkXsI8Mq7VStoz2ZYi0hEr5QvJS/B3gjzGNdQobu
47+
85jhMrRr07u0ecPDeqKLBKD2dmV9xoojwdJZCWfQAbOurXX7yGfqlmdlML9vbeqv
48+
r5iKqQCxY4Ju+a7kYItDZbOIf9kK8oeBO0pegeadAoGAfYi3Sl3wYzaP44TKmjNq
49+
3Z0NQChIcSzRDfEo25bioBzdlwf3tRBHTdPwdXhLJVTI/I90WMAcAgKaXlotrnMT
50+
HultzBviGb7LdUt1cNnjS9C+Cl8tCYePUx+Wg+pQruYX3fAo27G0GlIC8CIQz79M
51+
ElVV8gBIxYwuivacl3w9B6E=
52+
-----END PRIVATE KEY-----`;
2553

2654
export const baseConfig: UserClientConfigurationParams = {
2755
storeId: OPENFGA_STORE_ID,

tests/index.test.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
baseConfig,
3434
defaultConfiguration,
3535
OPENFGA_API_TOKEN_ISSUER,
36+
OPENFGA_CLIENT_ASSERTION_SIGNING_KEY,
3637
} from "./helpers/default-config";
3738
import { getNocks } from "./helpers/nocks";
3839

@@ -116,7 +117,7 @@ describe("OpenFGA SDK", function () {
116117
method: CredentialsMethod.ClientCredentials,
117118
} as AuthCredentialsConfig,
118119
})
119-
).toThrowError();
120+
).toThrow("config.clientId");
120121

121122
expect(
122123
() =>
@@ -130,7 +131,7 @@ describe("OpenFGA SDK", function () {
130131
}
131132
} as Configuration["credentials"]
132133
})
133-
).toThrowError();
134+
).toThrow("config.clientId");
134135

135136
expect(
136137
() =>
@@ -139,12 +140,28 @@ describe("OpenFGA SDK", function () {
139140
credentials: {
140141
method: CredentialsMethod.ClientCredentials,
141142
config: {
142-
...(baseConfig.credentials as any)!.clientCredentials,
143+
...(baseConfig.credentials as any)!.config,
143144
clientSecret: undefined!
144145
}
145146
} as Configuration["credentials"]
146147
})
147-
).toThrowError();
148+
).toThrow("config.clientSecret or config.clientAssertionSigningKey");
149+
150+
expect(
151+
() =>
152+
new OpenFgaApi({
153+
...baseConfig,
154+
credentials: {
155+
method: CredentialsMethod.ClientCredentials,
156+
config: {
157+
...(baseConfig.credentials as any)!.config,
158+
clientSecret: undefined!,
159+
clientAssertionSigningKey: undefined!
160+
}
161+
} as Configuration["credentials"]
162+
})
163+
).toThrow("config.clientSecret or config.clientAssertionSigningKey");
164+
148165

149166
expect(
150167
() =>
@@ -158,7 +175,7 @@ describe("OpenFGA SDK", function () {
158175
}
159176
} as Configuration["credentials"]
160177
})
161-
).toThrowError();
178+
).toThrow("config.apiAudience");
162179

163180
expect(
164181
() =>
@@ -172,7 +189,7 @@ describe("OpenFGA SDK", function () {
172189
}
173190
} as Configuration["credentials"]
174191
})
175-
).toThrowError();
192+
).toThrow("config.apiTokenIssuer");
176193
});
177194

178195
it("should issue a network call to get the token at the first request if client id is provided", async () => {
@@ -245,13 +262,39 @@ describe("OpenFGA SDK", function () {
245262
nock.cleanAll();
246263
});
247264

265+
266+
it("should issue a network call to get the token at the first request if client assertion is provided", async () => {
267+
const scope = nocks.tokenExchange(OPENFGA_API_TOKEN_ISSUER);
268+
nocks.readAuthorizationModels(baseConfig.storeId!);
269+
270+
const fgaApi = new OpenFgaApi({
271+
...baseConfig,
272+
credentials: {
273+
method: CredentialsMethod.ClientCredentials,
274+
config: {
275+
...(baseConfig.credentials as any).config,
276+
clientAssertionSigningKey: OPENFGA_CLIENT_ASSERTION_SIGNING_KEY
277+
}
278+
}
279+
});
280+
expect(scope.isDone()).toBe(false);
281+
282+
await fgaApi.readAuthorizationModels(baseConfig.storeId!);
283+
284+
expect(scope.isDone()).toBe(true);
285+
286+
nock.cleanAll();
287+
});
288+
289+
290+
248291
it("should allow passing in a configuration instance", async () => {
249292
const configuration = new Configuration(baseConfig);
250293
expect(() => new OpenFgaApi(configuration)).not.toThrowError();
251294
});
252295

296+
253297
it("should only accept valid telemetry attributes", async () => {
254-
255298
expect(
256299
() =>
257300
new OpenFgaApi({

0 commit comments

Comments
 (0)