Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/clients/js/CHANGELOG.md.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@

## [Unreleased](https://github.com/openfga/js-sdk/compare/v{{packageVersion}}...HEAD)

## v0.9.0

### [v0.9.0](https://github.com/openfga/js-sdk/compare/v0.8.1...v0.9.0) (2025-06-04)

- feat: support client assertion for client credentials authentication (#228)

## v0.8.1

### [v0.8.1](https://github.com/openfga/js-sdk/compare/v0.8.0...v0.8.1) (2025-04-24)
Expand Down
2 changes: 1 addition & 1 deletion config/clients/js/config.overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"sdkId": "js",
"gitRepoId": "js-sdk",
"packageName": "@openfga/sdk",
"packageVersion": "0.8.1",
"packageVersion": "0.9.0",
"packageDescription": "JavaScript and Node.js SDK for OpenFGA",
"packageDetailedDescription": "This is an autogenerated JavaScript SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api), and includes TS typings.",
"npmRegistry": "https://registry.npmjs.org/",
Expand Down
8 changes: 1 addition & 7 deletions config/clients/js/template/configuration.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,7 @@ export class Configuration {
case CredentialsMethod.ClientCredentials:
this.credentials = {
method: CredentialsMethod.ClientCredentials,
config: {
// We are only copying them from the passed in params here. We will be validating that they are valid in the Credentials constructor
clientId: credentialParams.config.clientId,
clientSecret: credentialParams.config.clientSecret,
apiAudience: credentialParams.config.apiAudience,
apiTokenIssuer: credentialParams.config.apiTokenIssuer,
}
config: credentialParams.config
};
break;
case CredentialsMethod.None:
Expand Down
74 changes: 60 additions & 14 deletions config/clients/js/template/credentials/credentials.ts.mustache
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
{{>partial_header}}

import globalAxios, { AxiosInstance } from "axios";
import * as jose from "jose";

import { assertParamExists, isWellFormedUriString } from "../validation";
import { FgaApiAuthenticationError, FgaApiError, FgaValidationError } from "../errors";
import { attemptHttpRequest } from "../common";
import { AuthCredentialsConfig, ClientCredentialsConfig, CredentialsMethod } from "./types";
import { AuthCredentialsConfig, PrivateKeyJWTConfig, ClientCredentialsConfig, ClientSecretConfig, CredentialsMethod } from "./types";
import { TelemetryAttributes } from "../telemetry/attributes";
import { TelemetryCounters } from "../telemetry/counters";
import { TelemetryConfiguration } from "../telemetry/configuration";
import { randomUUID } from "crypto";

interface ClientSecretRequest {
client_id: string;
client_secret: string;
audience: string;
grant_type: "client_credentials";
}

interface ClientAssertionRequest {
client_id: string;
client_assertion: string;
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
audience: string;
}

export class Credentials {
private accessToken?: string;
Expand Down Expand Up @@ -62,9 +78,9 @@ export class Credentials {
break;
case CredentialsMethod.ClientCredentials:
assertParamExists("Credentials", "config.clientId", authConfig.config?.clientId);
assertParamExists("Credentials", "config.clientSecret", authConfig.config?.clientSecret);
assertParamExists("Credentials", "config.apiTokenIssuer", authConfig.config?.apiTokenIssuer);
assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience);
assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey);

if (!isWellFormedUriString(`https://${authConfig.config?.apiTokenIssuer}`)) {
throw new FgaValidationError(
Expand Down Expand Up @@ -118,25 +134,16 @@ export class Credentials {
private async refreshAccessToken() {
const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config;
const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`;
const credentialsPayload = await this.buildClientAuthenticationPayload();

try {
const wrappedResponse = await attemptHttpRequest<{
client_id: string,
client_secret: string,
audience: string,
grant_type: "client_credentials",
}, {
const wrappedResponse = await attemptHttpRequest<ClientSecretRequest|ClientAssertionRequest, {
access_token: string,
expires_in: number,
}>({
url,
method: "POST",
data: {
client_id: clientCredentials.clientId,
client_secret: clientCredentials.clientSecret,
audience: clientCredentials.apiAudience,
grant_type: "client_credentials",
},
data: credentialsPayload,
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
Expand Down Expand Up @@ -188,4 +195,43 @@ export class Credentials {
throw err;
}
}

private async buildClientAuthenticationPayload(): Promise<ClientSecretRequest|ClientAssertionRequest> {
if (this.authConfig?.method !== CredentialsMethod.ClientCredentials) {
throw new FgaValidationError("Credentials method is not set to ClientCredentials");
}

const config = this.authConfig.config;
if ((config as PrivateKeyJWTConfig).clientAssertionSigningKey) {
const alg = (config as PrivateKeyJWTConfig).clientAssertionSigningAlgorithm || "RS256";
const privateKey = await jose.importPKCS8((config as PrivateKeyJWTConfig).clientAssertionSigningKey, alg);
const assertion = await new jose.SignJWT({})
.setProtectedHeader({ alg })
.setIssuedAt()
.setSubject(config.clientId)
.setJti(randomUUID())
.setIssuer(config.clientId)
.setAudience(`https://${config.apiTokenIssuer}/`)
.setExpirationTime("2m")
.sign(privateKey);
return {
...config.customClaims,
client_id: (config as PrivateKeyJWTConfig).clientId,
client_assertion: assertion,
audience: config.apiAudience,
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
grant_type: "client_credentials",
} as ClientAssertionRequest;
} else if ((config as ClientSecretConfig).clientSecret) {
return {
...config.customClaims,
client_id: (config as ClientSecretConfig).clientId,
client_secret: (config as ClientSecretConfig).clientSecret,
audience: (config as ClientSecretConfig).apiAudience,
grant_type: "client_credentials",
};
}

throw new FgaValidationError("Credentials method is set to ClientCredentials, but no clientSecret or clientAssertionSigningKey is provided");
}
}
40 changes: 34 additions & 6 deletions config/clients/js/template/credentials/types.ts.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,63 @@ export enum CredentialsMethod {
ClientCredentials = "client_credentials",
}

export interface ClientCredentialsConfig {
type BaseClientCredentialsConfig = {
/**
* Client ID
*
* @type {string}
* @memberof Configuration
*/
clientId: string;
/**
* API Token Issuer
*
* @type {string}
*/
apiTokenIssuer: string;
/**
* API Audience
*
* @type {string}
*/
apiAudience: string;
/**
* Claims to be included in the token exchange request.
*
* @type {Record<string, string>}
*/
customClaims?: Record<string, string>
}

export type ClientSecretConfig = BaseClientCredentialsConfig & {
/**
* Client Secret
*
* @type {string}
* @memberof Configuration
*/
clientSecret: string;

}
export type PrivateKeyJWTConfig = BaseClientCredentialsConfig & {
/**
* API Token Issuer
* Client assertion signing key
*
* @type {string}
* @memberof Configuration
*/
apiTokenIssuer: string;
clientAssertionSigningKey: string;
/**
* API Audience
*
* Client assertion signing algorithm,
* defaults to `RS256` if not specified.
* @type {string}
* @memberof Configuration
*/
apiAudience: string;
clientAssertionSigningAlgorithm?: string;
}

export type ClientCredentialsConfig = ClientSecretConfig | PrivateKeyJWTConfig;

export interface ApiTokenConfig {
/**
* API Token Value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
},
"dependencies": {
"@openfga/sdk": "file:../../",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-prometheus": "^0.52.1",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"dotenv": "^16.4.5"
"@opentelemetry/exporter-metrics-otlp-proto": "^0.57.2",
"@opentelemetry/exporter-prometheus": "^0.57.2",
"@opentelemetry/sdk-metrics": "^1.30.1",
"@opentelemetry/sdk-node": "^0.57.2",
"dotenv": "^16.4.7"
},
"engines": {
"node": ">=16.13.0"
Expand Down
17 changes: 9 additions & 8 deletions config/clients/js/template/package.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,21 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"axios": "^1.7.9",
"axios": "^1.8.3",
"jose": "^5.10.0",
"tiny-async-pool": "^2.1.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.10.5",
"@types/node": "^22.13.10",
"@types/tiny-async-pool": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"eslint": "^8.57.0",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"eslint": "^8.57.1",
"jest": "^29.7.0",
"nock": "^13.5.6",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2"
"nock": "^14.0.1",
"ts-jest": "^29.2.6",
"typescript": "^5.8.2"
},
"files": [
"CHANGELOG.md",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ export const {{appUpperCaseName}}_API_AUDIENCE = "https://api.{{sampleApiDomain}
export const {{appUpperCaseName}}_CLIENT_ID = "01H0H3D8TD07EWAQHXY9BWJG3V";
export const {{appUpperCaseName}}_CLIENT_SECRET = "this-is-very-secret";
export const {{appUpperCaseName}}_API_TOKEN = "fga_abcdef";
export const {{appUpperCaseName}}_CLIENT_ASSERTION_SIGNING_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmJ37Zi9/LS5/I
E5pl7XobscHSFNrTfZC9Jx15KF5iJLFb9s8twQdo/hWPC4adidu7gVCIGNGYBGH2
Q2z9nMrVA5TUQrrTsvJw0ldSWn2MeadZcMGI0AcaomOu8P7lxyaf/sgWAOgW1P+Y
SAEPHHvKuA0orQVVWYwt7jaaQ0GBwEh3XiwqiwUKCJQ06eeQVxXxGr9DBYtZJOzn
gLRj0wNF3WWU5JddV2o+CHRvpN1zLBHam3RXJQMdObs2waeR85AbfO6rNQr/Zscd
Y6XDsHjeAHOykfoMBBexK0Rdu3Vqk2DSaXG3HUC54sbCLZSDmo4S0Dsax1IEWnWs
rA8nD5O7AgMBAAECggEAWZzoNbFSJnhgEsmbNPO1t0HLq05Gc9FwwU2RGsMemM0b
p6ieO3zsszM3VraQqBds0IHFxvAO78dJE1dmgQsDKNSXptwCnXoQDuC/ckfcmY0m
nVsbZ/dDxNmUwaGBRht4TRSpeHPK6lTt3i+vBeC7zI9ERGG18WkH/TxC02a7g1aL
emz/SNgOdFkHPoKcgYyUp2Svh0aly9g2NbyIusNO4C9M/tCYRobcrZBRIognNZKY
bZVQrnilOClVcbND1oOPs0O6sxTMGd3eR7bS6w7i59vUCPwQSTo1L/FA23ZPY5kQ
AgeGZnp4Nve1Ecsvp48MJHb4cwJeysxH6hhyl3zMHQKBgQDzKmo1Sa5wAqTD4HAP
/aboYfSxhIE9c3qhj5HDP76PBZa8N5fyNyTJCsauk2a2/ZFOwCz142yF1JEbxHgb
j6XYYazVFfk2DFWb9Ul/zQMmCVcETlRhxIQPc76f9QjvAc20B6xeR3M14RwfK/u+
FaN7PsMAItH0xJRpGIWpwN/3PQKBgQDyTUY2WsGNUzMKarLyKX5KSDELfgmJtcAv
LunqhYnhks4i6PVknXIY4GuGhIhAanDFlFZIhTa5a2e2bNZvgRz+VxNNRsQQZPgt
M9Gg1fLSqQOL7OZn+cjkkYfxNE1FLMoStaANl6JkCjN4Ted2pLbswCBXwa4qsxRZ
bsA3BTWmVwKBgQCgqYSVAsLLZSPB+7dvCVPPNHF9HKRbmsIKnxZa3/IjAzlN0JmH
QuH+Jy2QyPlTrIPmeVj7ebEJV6Isq4oEA8w7BIYyIBuRl2K08cMHOsh6yC8DPFHK
axIqN3paq4akjBeCfJNpk2HO1pZDDkd9l0R1uMkUfO0mAQBh0/70YuhXrQKBgEbn
igZZ5I3grOz9cEQhFE3UdlWwmkXsI8Mq7VStoz2ZYi0hEr5QvJS/B3gjzGNdQobu
85jhMrRr07u0ecPDeqKLBKD2dmV9xoojwdJZCWfQAbOurXX7yGfqlmdlML9vbeqv
r5iKqQCxY4Ju+a7kYItDZbOIf9kK8oeBO0pegeadAoGAfYi3Sl3wYzaP44TKmjNq
3Z0NQChIcSzRDfEo25bioBzdlwf3tRBHTdPwdXhLJVTI/I90WMAcAgKaXlotrnMT
HultzBviGb7LdUt1cNnjS9C+Cl8tCYePUx+Wg+pQruYX3fAo27G0GlIC8CIQz79M
ElVV8gBIxYwuivacl3w9B6E=
-----END PRIVATE KEY-----`;

export const baseConfig: UserClientConfigurationParams = {
storeId: {{appUpperCaseName}}_STORE_ID,
Expand Down
Loading
Loading