├── .eslintignore ├── .openapi-generator ├── VERSION └── FILES ├── example ├── opentelemetry │ ├── .npmrc │ ├── .env.example │ ├── package.json │ ├── instrumentation.mjs │ ├── README.md │ └── opentelemetry.mjs ├── Makefile ├── example1 │ ├── package.json │ └── example1.mjs └── README.md ├── .npmrc ├── .codecov.yml ├── .openapi-generator-ignore ├── .github ├── CODEOWNERS ├── dependabot.yaml ├── ISSUE_TEMPLATE │ ├── config.yaml │ ├── feature_request.md │ ├── feature_request.yaml │ ├── bug_report.md │ └── bug_report.yaml ├── workflows │ ├── main.yaml │ └── scorecard.yml └── SECURITY-INSIGHTS.yml ├── .madgerc ├── tests ├── helpers │ ├── index.ts │ ├── default-config.ts │ └── nocks.ts ├── tsconfig.spec.json ├── telemetry │ ├── counters.test.ts │ ├── histograms.test.ts │ ├── metrics.test.ts │ ├── configuration.test.ts │ └── attributes.test.ts ├── jest.config.js ├── validation.test.ts └── headers.test.ts ├── utils ├── assert-never.ts ├── set-header-if-not-set.ts ├── index.ts ├── set-not-enumerable-property.ts ├── chunk-array.ts └── generate-random-id.ts ├── .fossa.yml ├── .gitignore ├── tsconfig.json ├── credentials ├── index.ts ├── types.ts └── credentials.ts ├── telemetry ├── counters.ts ├── histograms.ts ├── metrics.ts ├── attributes.ts └── configuration.ts ├── .eslintrc.js ├── index.ts ├── validation.ts ├── Makefile ├── base.ts ├── package.json ├── CONTRIBUTING.md ├── constants └── index.ts ├── configuration.ts ├── docs └── opentelemetry.md ├── errors.ts ├── LICENSE ├── common.ts └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 6.4.0 -------------------------------------------------------------------------------- /example/opentelemetry/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | sign-git-tag=true 3 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: "60...80" 4 | round: down 5 | -------------------------------------------------------------------------------- /.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | configuration.ts 3 | common.ts 4 | base.ts 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @openfga/dx 2 | README.md @openfga/product @openfga/community @openfga/dx 3 | -------------------------------------------------------------------------------- /.madgerc: -------------------------------------------------------------------------------- 1 | { 2 | "detectiveOptions": { 3 | "ts": { 4 | "skipTypeImports": true 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /tests/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { getNocks } from "./nocks"; 2 | export { baseConfig, defaultConfiguration } from "./default-config"; 3 | -------------------------------------------------------------------------------- /utils/assert-never.ts: -------------------------------------------------------------------------------- 1 | export function assertNever(value: never): never { 2 | throw new Error(`Assertion failed. Unacceptable value: '${value}'`); 3 | } 4 | -------------------------------------------------------------------------------- /utils/set-header-if-not-set.ts: -------------------------------------------------------------------------------- 1 | export function setHeaderIfNotSet(headers: Record, key: string, value: string): void { 2 | if (!headers[key] && value) { 3 | headers[key] = value; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./assert-never"; 2 | export * from "./chunk-array"; 3 | export * from "./generate-random-id"; 4 | export * from "./set-header-if-not-set"; 5 | export * from "./set-not-enumerable-property"; 6 | -------------------------------------------------------------------------------- /.fossa.yml: -------------------------------------------------------------------------------- 1 | version: 3 2 | 3 | server: https://app.fossa.com 4 | 5 | project: 6 | id: github.com/openfga/js-sdk 7 | name: github.com/openfga/js-sdk 8 | link: openfga.dev 9 | url: github.com/openfga/js-sdk 10 | -------------------------------------------------------------------------------- /utils/set-not-enumerable-property.ts: -------------------------------------------------------------------------------- 1 | export function setNotEnumerableProperty(entity: object, property: string, value: T) { 2 | Object.defineProperty(entity, property, { 3 | enumerable: false, 4 | writable: false, 5 | value: value 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /tests/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest", "node"], 5 | "noEmit": true, 6 | "rootDir": "../" 7 | }, 8 | "include": [ 9 | "../**/*.ts", 10 | "./*.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /example/opentelemetry/.env.example: -------------------------------------------------------------------------------- 1 | # Configuration for OpenFGA 2 | FGA_CLIENT_ID= 3 | FGA_API_TOKEN_ISSUER= 4 | FGA_API_AUDIENCE= 5 | FGA_CLIENT_SECRET= 6 | FGA_STORE_ID= 7 | FGA_AUTHORIZATION_MODEL_ID= 8 | FGA_API_URL="http://localhost:8080" 9 | 10 | # Configuration for OpenTelemetry 11 | OTEL_SERVICE_NAME="openfga-opentelemetry-example" -------------------------------------------------------------------------------- /utils/chunk-array.ts: -------------------------------------------------------------------------------- 1 | export function chunkArray(inputArray: T[], maxChunkSize: number): T[][] { 2 | const arrayOfArrays = []; 3 | 4 | const inputArrayClone = [...inputArray]; 5 | while (inputArrayClone.length > 0) { 6 | arrayOfArrays.push(inputArrayClone.splice(0, maxChunkSize)); 7 | } 8 | 9 | return arrayOfArrays; 10 | } 11 | -------------------------------------------------------------------------------- /.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .codecov.yml 2 | .github/CODEOWNERS 3 | .github/ISSUE_TEMPLATE/bug_report.yaml 4 | .github/ISSUE_TEMPLATE/config.yaml 5 | .github/ISSUE_TEMPLATE/feature_request.yaml 6 | .gitignore 7 | .npmrc 8 | CONTRIBUTING.md 9 | LICENSE 10 | README.md 11 | VERSION.txt 12 | api.ts 13 | apiModel.ts 14 | constants/index.ts 15 | git_push.sh 16 | index.ts 17 | -------------------------------------------------------------------------------- /example/Makefile: -------------------------------------------------------------------------------- 1 | all: run 2 | 3 | project_name=example1 4 | openfga_version=latest 5 | 6 | setup: 7 | npm --prefix "${project_name}" install 8 | 9 | run: 10 | npm --prefix "${project_name}" run start 11 | 12 | run-openfga: 13 | docker pull docker.io/openfga/openfga:${openfga_version} && \ 14 | docker run -p 8080:8080 docker.io/openfga/openfga:${openfga_version} run -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | wwwroot/*.js 2 | node_modules 3 | typings 4 | dist 5 | coverage 6 | 7 | 8 | # IDEs 9 | .idea 10 | .vscode 11 | .sublime-workspace 12 | .sublime-project 13 | .idea/ 14 | .vscode/ 15 | 16 | # Possible credential files 17 | .env 18 | credentials.json 19 | 20 | # git conflict leftover files 21 | *.orig 22 | 23 | # Mac 24 | .DS_Store 25 | 26 | VERSION.txt 27 | git_push.sh 28 | 29 | example/**/package-lock.json 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "target": "es2020", 5 | "module": "commonjs", 6 | "noImplicitAny": true, 7 | "strict": true, 8 | "outDir": "dist", 9 | "rootDir": ".", 10 | "typeRoots": [ 11 | "node_modules/@types" 12 | ] 13 | }, 14 | "exclude": [ 15 | "dist", 16 | "node_modules", 17 | "**/*.test.ts", 18 | "tests" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /example/example1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example1", 3 | "private": "true", 4 | "version": "1.0.0", 5 | "description": "A bare bones example of using the OpenFGA SDK", 6 | "author": "OpenFGA", 7 | "license": "Apache-2.0", 8 | "scripts": { 9 | "start": "node example1.mjs" 10 | }, 11 | "dependencies": { 12 | "@openfga/sdk": "^0.9.1" 13 | }, 14 | "engines": { 15 | "node": ">=16.13.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /credentials/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | export * from "./credentials"; 15 | export * from "./types"; 16 | -------------------------------------------------------------------------------- /tests/telemetry/counters.test.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryCounters } from "../../telemetry/counters"; 2 | 3 | describe("TelemetryCounters", () => { 4 | test("should have correct counter details", () => { 5 | expect(TelemetryCounters.credentialsRequest.name).toBe("fga-client.credentials.request"); 6 | expect(TelemetryCounters.credentialsRequest.unit).toBe("milliseconds"); 7 | expect(TelemetryCounters.credentialsRequest.description).toBe("The number of times an access token is requested."); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | rootDir: "../", 4 | testEnvironment: "node", 5 | moduleFileExtensions: ["js", "d.ts", "ts", "json"], 6 | collectCoverage: true, 7 | coverageReporters: ["text", "cobertura", "lcov"], 8 | collectCoverageFrom: [ 9 | "**/**.{ts,tsx,js,jsx}", 10 | "!**/**.d.ts", 11 | "!**/**.eslintrc.js", 12 | "!**/coverage/**", 13 | "!**/dist/**", 14 | "!**/example/**", 15 | "!**/node_modules/**", 16 | "!**/tests/**", 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json 2 | version: 2 3 | 4 | updates: 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | groups: 10 | dependencies: 11 | patterns: 12 | - "*" 13 | exclude-patterns: 14 | - "eslint" 15 | 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | groups: 21 | dependencies: 22 | patterns: 23 | - "*" 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 📖 OpenFGA's Documentation 4 | url: https://openfga.dev/docs 5 | about: Check OpenFGA's documentation for an in-depth overview 6 | - name: 👽 Community 7 | url: https://openfga.dev/community 8 | about: Join OpenFGA's community on Slack and GitHub Discussions 9 | - name: 📝 RFCs 10 | url: https://github.com/openfga/rfcs 11 | about: Check existing RFCs to understand where the project is headed 12 | - name: 💬 Discussions 13 | url: https://github.com/orgs/openfga/discussions 14 | about: Start a discussion about your authorization needs or questions 15 | -------------------------------------------------------------------------------- /utils/generate-random-id.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID, randomBytes } from "crypto"; 2 | 3 | /** 4 | * Generates a random ID 5 | * 6 | * Note: May not return a valid value on older browsers - we're fine with this for now 7 | */ 8 | export function generateRandomId(): string | undefined { 9 | if (typeof randomUUID === "function") { 10 | return randomUUID(); 11 | } 12 | 13 | if (typeof randomBytes === "function") { 14 | // Fallback for older node versions 15 | return randomBytes(20).toString("hex"); 16 | } 17 | 18 | // For older browsers 19 | return; 20 | } 21 | 22 | export function generateRandomIdWithNonUniqueFallback(): string { 23 | return generateRandomId() || "00000000-0000-0000-0000-000000000000"; 24 | } 25 | -------------------------------------------------------------------------------- /example/opentelemetry/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openfga-opentelemetry-example", 3 | "private": "true", 4 | "version": "1.0.0", 5 | "description": "A bare bones example of using the OpenFGA SDK with OpenTelemetry metrics reporting", 6 | "author": "OpenFGA", 7 | "license": "Apache-2.0", 8 | "scripts": { 9 | "start": "node --import ./instrumentation.mjs opentelemetry.mjs" 10 | }, 11 | "dependencies": { 12 | "@openfga/sdk": "file:../../", 13 | "@opentelemetry/exporter-metrics-otlp-proto": "^0.57.2", 14 | "@opentelemetry/exporter-prometheus": "^0.57.2", 15 | "@opentelemetry/sdk-metrics": "^1.30.1", 16 | "@opentelemetry/sdk-node": "^0.57.2", 17 | "dotenv": "^16.4.7" 18 | }, 19 | "engines": { 20 | "node": ">=16.13.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/opentelemetry/instrumentation.mjs: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { NodeSDK } from "@opentelemetry/sdk-node"; 3 | import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; 4 | import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; 5 | import process from "process"; 6 | 7 | const sdk = new NodeSDK({ 8 | metricReader: new PeriodicExportingMetricReader({ 9 | exportIntervalMillis: 5000, 10 | exporter: new OTLPMetricExporter({ 11 | concurrencyLimit: 1, 12 | }), 13 | }), 14 | }); 15 | sdk.start(); 16 | 17 | process.on("exit", () => { 18 | sdk 19 | .shutdown() 20 | .then( 21 | () => console.log("SDK shut down successfully"), 22 | (err) => console.log("Error shutting down SDK", err) 23 | ) 24 | .finally(() => process.exit(0)); 25 | }); -------------------------------------------------------------------------------- /telemetry/counters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | export interface TelemetryCounter { 15 | name: string; 16 | unit: string; 17 | description: string; 18 | } 19 | 20 | export class TelemetryCounters { 21 | static credentialsRequest: TelemetryCounter = { 22 | name: "fga-client.credentials.request", 23 | unit: "milliseconds", 24 | description: "The number of times an access token is requested.", 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /tests/telemetry/histograms.test.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryHistograms } from "../../telemetry/histograms"; 2 | 3 | describe("TelemetryHistograms", () => { 4 | test("should have correct histogram details for request duration", () => { 5 | expect(TelemetryHistograms.requestDuration.name).toBe("fga-client.request.duration"); 6 | expect(TelemetryHistograms.requestDuration.unit).toBe("milliseconds"); 7 | expect(TelemetryHistograms.requestDuration.description).toBe("How long it took for a request to be fulfilled."); 8 | }); 9 | 10 | test("should have correct histogram details for query duration", () => { 11 | expect(TelemetryHistograms.queryDuration.name).toBe("fga-client.query.duration"); 12 | expect(TelemetryHistograms.queryDuration.unit).toBe("milliseconds"); 13 | expect(TelemetryHistograms.queryDuration.description).toBe("How long it took to perform a query request."); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 13, 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint" 18 | ], 19 | "rules": { 20 | "@typescript-eslint/no-unused-vars": ["warn"], 21 | "@typescript-eslint/no-explicit-any": ["warn"], 22 | "@typescript-eslint/no-require-imports":["error", { allowAsImport: true }], 23 | "indent": [ 24 | "error", 25 | 2 26 | ], 27 | "linebreak-style": [ 28 | "error", 29 | "unix" 30 | ], 31 | "quotes": [ 32 | "error", 33 | "double" 34 | ], 35 | "semi": [ 36 | "error", 37 | "always" 38 | ] 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * JavaScript and Node.js SDK for OpenFGA 5 | * 6 | * API version: 1.x 7 | * Website: https://openfga.dev 8 | * Documentation: https://openfga.dev/docs 9 | * Support: https://openfga.dev/community 10 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 11 | * 12 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 13 | */ 14 | 15 | 16 | export * from "./api"; 17 | export * from "./client"; 18 | export * from "./apiModel"; 19 | export { Configuration, UserConfigurationParams, GetDefaultRetryParams } from "./configuration"; 20 | export { Credentials, CredentialsMethod } from "./credentials"; 21 | export * from "./telemetry/attributes"; 22 | export * from "./telemetry/configuration"; 23 | export * from "./telemetry/counters"; 24 | export * from "./telemetry/histograms"; 25 | export * from "./telemetry/metrics"; 26 | export * from "./errors"; 27 | 28 | 29 | -------------------------------------------------------------------------------- /telemetry/histograms.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | export interface TelemetryHistogram { 15 | name: string; 16 | unit: string; 17 | description: string; 18 | } 19 | 20 | export class TelemetryHistograms { 21 | static requestDuration: TelemetryHistogram = { 22 | name: "fga-client.request.duration", 23 | unit: "milliseconds", 24 | description: "How long it took for a request to be fulfilled.", 25 | }; 26 | 27 | static queryDuration: TelemetryHistogram = { 28 | name: "fga-client.query.duration", 29 | unit: "milliseconds", 30 | description: "How long it took to perform a query request.", 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /validation.ts: -------------------------------------------------------------------------------- 1 | import { FgaRequiredParamError } from "./errors"; 2 | 3 | /** 4 | * 5 | * @throws { FgaRequiredParamError } 6 | * @export 7 | */ 8 | export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { 9 | if (paramValue === null || paramValue === undefined) { 10 | throw new FgaRequiredParamError(functionName, paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); 11 | } 12 | }; 13 | 14 | /** 15 | * 16 | * @export 17 | */ 18 | export const isWellFormedUriString = (uri: string): boolean => { 19 | try { 20 | const uriResult = new URL(uri); 21 | return ((uriResult.toString() === uri || uriResult.toString() === `${uri}/`) && 22 | (uriResult.protocol === "https:" || uriResult.protocol === "http:")); 23 | } catch (err) { 24 | return false; 25 | } 26 | }; 27 | 28 | export const isWellFormedUlidString = (ulid: string): boolean => { 29 | const regex = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/; 30 | return !!(typeof ulid === "string" && ulid.match(regex)?.length); 31 | }; 32 | -------------------------------------------------------------------------------- /example/opentelemetry/README.md: -------------------------------------------------------------------------------- 1 | # OpenTelemetry usage with OpenFGA's JS SDK 2 | 3 | This example demonstrates how you can use OpenTelemetry with OpenFGA's JS SDK. 4 | 5 | ## Prerequisites 6 | 7 | If you do not already have an OpenFGA instance running, you can start one using the following command: 8 | 9 | ```bash 10 | docker run -d -p 8080:8080 openfga/openfga 11 | ``` 12 | 13 | You need to have an OpenTelemetry collector running to receive data. A pre-configured collector is available using Docker: 14 | 15 | ```bash 16 | git clone https://github.com/ewanharris/opentelemetry-collector-dev-setup.git 17 | cd opentelemetry-collector-dev-setup 18 | docker-compose up -d 19 | ``` 20 | 21 | ## Configure the example 22 | 23 | You need to configure the example for your environment: 24 | 25 | ```bash 26 | cp .env.example .env 27 | ``` 28 | 29 | Now edit the `.env` file and set the values as appropriate. 30 | 31 | ## Running the example 32 | 33 | Begin by installing the required dependencies: 34 | 35 | ```bash 36 | npm i 37 | ``` 38 | 39 | Next, run the example: 40 | 41 | ```bash 42 | npm start 43 | ``` -------------------------------------------------------------------------------- /tests/validation.test.ts: -------------------------------------------------------------------------------- 1 | import { isWellFormedUlidString } from "../validation"; 2 | 3 | describe("validation.ts", () => { 4 | describe("isWellFormedUlidString", () => { 5 | it("should return true on valid ulids", async () => { 6 | const ulids: string[] = [ 7 | "01H0GVCS1HCQM6SJRJ4A026FZ9", 8 | "01H0GVD9ACPFKGMWJV0Y93ZM7H", 9 | "01H0GVDH0FRZ4WAFED6T9KZYZR", 10 | "01H0GVDSW72AZ8QV3R0HJ91QBX", 11 | ]; 12 | ulids.forEach(ulid => { 13 | const result = isWellFormedUlidString(ulid); 14 | expect(result).toBe(true); 15 | }); 16 | }); 17 | 18 | it("should return false on invalid ulids", async () => { 19 | const ulids: string[] = [ 20 | "abc", 21 | 123 as any, 22 | null, 23 | "01H0GVDSW72AZ8QV3R0HJ91QBXa", 24 | "b523ad13-8adb-4803-a6db-013ac50197ca", 25 | "9240BFC0-DA00-457B-A328-FC370A598D60", 26 | ]; 27 | ulids.forEach(ulid => { 28 | const result = isWellFormedUlidString(ulid); 29 | expect(result).toBe(false); 30 | }); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help install build test lint lint-fix audit clean all 2 | 3 | # Default target 4 | help: 5 | @echo "Available targets:" 6 | @echo " make install - Install dependencies" 7 | @echo " make build - Build the project" 8 | @echo " make test - Run tests" 9 | @echo " make lint - Run linter" 10 | @echo " make lint-fix - Run linter with auto-fix" 11 | @echo " make audit - Audit dependencies for vulnerabilities" 12 | @echo " make clean - Clean build artifacts" 13 | @echo " make all - Install, build, lint, check and test" 14 | 15 | # Install dependencies 16 | install: 17 | npm ci 18 | 19 | # Build the project 20 | build: 21 | npm run build 22 | 23 | # Run tests 24 | test: 25 | npm test 26 | 27 | # Run linter 28 | lint: 29 | npm run lint 30 | 31 | # Check lint, test and audit 32 | check: lint audit test 33 | 34 | # Run linter with auto-fix 35 | lint-fix: 36 | npm run lint:fix 37 | 38 | # Audit dependencies 39 | audit: 40 | npm audit 41 | 42 | # Clean build artifacts 43 | clean: 44 | rm -rf dist/ 45 | 46 | # Run all checks (install, build, lint, check and test) 47 | all: install build check clean 48 | @echo "All checks passed!" 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest new functionality for this project. 4 | title: '' 5 | labels: 'feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Please do not report security vulnerabilities here**. See the [Responsible Disclosure Program](https://github.com/openfga/js-sdk/blob/main/.github/SECURITY.md). 11 | 12 | **Thank you in advance for helping us to improve this library!** Please read through the template below and answer all relevant questions. Your additional work here is greatly appreciated and will help us respond as quickly as possible. 13 | 14 | By submitting an issue to this repository, you agree to the terms within the [OpenFGA Code of Conduct](https://github.com/openfga/rfcs/blob/main/CODE-OF-CONDUCT.md). 15 | 16 | ### Describe the problem you'd like to have solved 17 | 18 | > A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 19 | 20 | ### Describe the ideal solution 21 | 22 | > A clear and concise description of what you want to happen. 23 | 24 | ## Alternatives and current workarounds 25 | 26 | > A clear and concise description of any alternatives you've considered or any workarounds that are currently in place. 27 | 28 | ### Additional context 29 | 30 | > Add any other context or screenshots about the feature request here. 31 | -------------------------------------------------------------------------------- /base.ts: -------------------------------------------------------------------------------- 1 | import globalAxios, { AxiosInstance } from "axios"; 2 | import * as http from "http"; 3 | import * as https from "https"; 4 | 5 | import { Configuration, UserConfigurationParams } from "./configuration"; 6 | import { Credentials } from "./credentials"; 7 | 8 | const DEFAULT_CONNECTION_TIMEOUT_IN_MS = 10000; 9 | 10 | /** 11 | * 12 | * @export 13 | * @interface RequestArgs 14 | */ 15 | export interface RequestArgs { 16 | url: string; 17 | options: any; 18 | } 19 | 20 | /** 21 | * 22 | * @export 23 | * @class BaseAPI 24 | */ 25 | export class BaseAPI { 26 | protected configuration: Configuration; 27 | protected credentials: Credentials; 28 | 29 | constructor(configuration: UserConfigurationParams | Configuration, protected axios?: AxiosInstance) { 30 | if (configuration instanceof Configuration) { 31 | this.configuration = configuration; 32 | } else { 33 | this.configuration = new Configuration(configuration); 34 | } 35 | this.configuration.isValid(); 36 | 37 | this.credentials = Credentials.init(this.configuration, this.axios); 38 | 39 | if (!this.axios) { 40 | const httpAgent = new http.Agent({ keepAlive: true }); 41 | const httpsAgent = new https.Agent({ keepAlive: true }); 42 | this.axios = globalAxios.create({ 43 | httpAgent, 44 | httpsAgent, 45 | timeout: DEFAULT_CONNECTION_TIMEOUT_IN_MS, 46 | headers: this.configuration.baseOptions?.headers, 47 | }); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ## Examples of using the OpenFGA JS SDK 2 | 3 | A set of Examples on how to call the OpenFGA JS SDK 4 | 5 | ### Examples 6 | Example 1: 7 | A bare-bones example. It creates a store and runs a set of calls against it including creating a model, writing tuples, and checking for access. 8 | 9 | OpenTelemetry: 10 | An example that demonstrates how to integrate the OpenFGA JS SDK with OpenTelemetry. 11 | 12 | ### Running the Examples 13 | 14 | Prerequisites: 15 | - `docker` 16 | - `make` 17 | - `Node.js` 16.13.0+ 18 | 19 | #### Run using a published SDK 20 | 21 | Steps 22 | 1. Clone/Copy the example folder 23 | 2. If you have an OpenFGA server running, you can use it, otherwise run `make run-openfga` to spin up an instance (you'll need to switch to a different terminal after - don't forget to close it when done) 24 | 3. Run `make setup` to install dependencies 25 | 4. Run `make run` to run the example 26 | 27 | #### Run using a local unpublished SDK build 28 | 29 | Steps 30 | 1. Build the SDK 31 | 2. In the Example `package.json` change the `@openfga/sdk` dependency from a semver range like below 32 | ```json 33 | "dependencies": { 34 | "@openfga/sdk": "^0.9.1" 35 | } 36 | ``` 37 | to a `file:` reference like below 38 | ```json 39 | "dependencies": { 40 | "@openfga/sdk": "file:../../" 41 | } 42 | ``` 43 | 3. If you have an OpenFGA server running, you can use it, otherwise run `make run-openfga` to spin up an instance (you'll need to switch to a different terminal after - don't forget to close it when done) 44 | 4. Run `make setup` to install dependencies 45 | 5. Run `make run` to run the example 46 | -------------------------------------------------------------------------------- /tests/telemetry/metrics.test.ts: -------------------------------------------------------------------------------- 1 | import { MetricRecorder } from "../../telemetry/metrics"; 2 | import { TelemetryCounters } from "../../telemetry/counters"; 3 | import { TelemetryHistograms } from "../../telemetry/histograms"; 4 | import { TelemetryAttributes } from "../../telemetry/attributes"; 5 | 6 | jest.mock("@opentelemetry/api", () => ({ 7 | metrics: { 8 | getMeter: jest.fn().mockReturnValue({ 9 | createCounter: jest.fn().mockReturnValue({ add: jest.fn() }), 10 | createHistogram: jest.fn().mockReturnValue({ record: jest.fn() }), 11 | }), 12 | }, 13 | })); 14 | 15 | describe("TelemetryMetrics", () => { 16 | let telemetryMetrics: MetricRecorder; 17 | 18 | beforeEach(() => { 19 | telemetryMetrics = new MetricRecorder(); 20 | }); 21 | 22 | test("should create a counter and add a value", () => { 23 | const counter = telemetryMetrics.counter(TelemetryCounters.credentialsRequest, 5); 24 | 25 | expect(counter).toBeDefined(); 26 | expect(counter.add).toHaveBeenCalledWith(5, undefined); 27 | }); 28 | 29 | test("should create a histogram and record a value", () => { 30 | const histogram = telemetryMetrics.histogram(TelemetryHistograms.requestDuration, 200); 31 | 32 | expect(histogram).toBeDefined(); 33 | expect(histogram.record).toHaveBeenCalledWith(200, undefined); 34 | }); 35 | 36 | test("should handle creating metrics with custom attributes", () => { 37 | const attributes = TelemetryAttributes.prepare({ "http.host": "example.com" }); 38 | const counter = telemetryMetrics.counter(TelemetryCounters.credentialsRequest, 3, attributes); 39 | 40 | expect(counter.add).toHaveBeenCalledWith(3, attributes); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openfga/sdk", 3 | "version": "0.9.1", 4 | "description": "JavaScript and Node.js SDK for OpenFGA", 5 | "author": "OpenFGA", 6 | "keywords": [ 7 | "openfga", 8 | "authorization", 9 | "fga", 10 | "fine-grained-authorization", 11 | "rebac", 12 | "zanzibar" 13 | ], 14 | "license": "Apache-2.0", 15 | "main": "./dist/index.js", 16 | "typings": "./dist/index.d.ts", 17 | "scripts": { 18 | "build": "tsc --outDir dist/", 19 | "prepublishOnly": "rm -rf dist/ && npm run build", 20 | "test": "jest --config ./tests/jest.config.js", 21 | "lint": "eslint . --ext .ts", 22 | "lint:fix": "eslint . --ext .ts --fix" 23 | }, 24 | "dependencies": { 25 | "@opentelemetry/api": "^1.9.0", 26 | "axios": "^1.12.2", 27 | "jose": "^5.10.0", 28 | "tiny-async-pool": "^2.1.0" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^29.5.14", 32 | "@types/node": "^25.0.2", 33 | "@types/tiny-async-pool": "^2.0.3", 34 | "@typescript-eslint/eslint-plugin": "^8.39.0", 35 | "@typescript-eslint/parser": "^8.39.0", 36 | "eslint": "^8.57.1", 37 | "jest": "^29.7.0", 38 | "nock": "^14.0.9", 39 | "ts-jest": "^29.4.1", 40 | "typescript": "^5.9.2" 41 | }, 42 | "files": [ 43 | "CHANGELOG.md", 44 | "LICENSE", 45 | "README.md", 46 | "dist" 47 | ], 48 | "repository": { 49 | "type": "git", 50 | "url": "git://github.com:openfga/js-sdk.git" 51 | }, 52 | "bugs": { 53 | "url": "https://github.com/openfga/js-sdk/issues" 54 | }, 55 | "homepage": "https://github.com/openfga/js-sdk#readme", 56 | "engines": { 57 | "node": ">=16.15.0" 58 | }, 59 | "publishConfig": { 60 | "access": "public", 61 | "provenance": true 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea or a feature for this project 3 | labels: [ "enhancement" ] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this feature request! 10 | 11 | - type: checkboxes 12 | id: checklist 13 | attributes: 14 | label: Checklist 15 | options: 16 | - label: I agree to the terms within the [OpenFGA Code of Conduct](https://github.com/openfga/.github/blob/main/CODE_OF_CONDUCT.md). 17 | required: true 18 | 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: Describe the problem you'd like to have solved 23 | description: A clear and concise description of what the problem is. 24 | placeholder: My life would be a lot simpler if... 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | id: ideal-solution 30 | attributes: 31 | label: Describe the ideal solution 32 | description: A clear and concise description of what you want to happen. 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: alternatives-and-workarounds 38 | attributes: 39 | label: Alternatives and current workarounds 40 | description: A clear and concise description of any alternatives you've considered or any workarounds that are currently in place. 41 | validations: 42 | required: false 43 | 44 | - type: textarea 45 | id: references 46 | attributes: 47 | label: References 48 | description: Any references to other issues, PRs, documentation or other links 49 | validations: 50 | required: false 51 | 52 | - type: textarea 53 | id: additional-context 54 | attributes: 55 | label: Additional context 56 | description: Add any other context or screenshots about the feature request here. 57 | validations: 58 | required: false 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Report an issue 3 | about: Create a bug report about an existing issue. 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Please do not report security vulnerabilities here**. See the [Responsible Disclosure Program](https://github.com/openfga/js-sdk/blob/main/.github/SECURITY.md). 11 | 12 | **Thank you in advance for helping us to improve this library!** Please read through the template below and answer all relevant questions. Your additional work here is greatly appreciated and will help us respond as quickly as possible. 13 | 14 | By submitting an issue to this repository, you agree to the terms within the [OpenFGA Code of Conduct](https://github.com/openfga/rfcs/blob/main/CODE-OF-CONDUCT.md). 15 | 16 | ### Description 17 | 18 | > Provide a clear and concise description of the issue, including what you expected to happen. 19 | 20 | ### Version of SDK 21 | 22 | > v0.2.0 23 | 24 | ### Version of OpenFGA (if known) 25 | 26 | > v1.1.0 27 | 28 | ### OpenFGA Flags/Custom Configuration Applicable 29 | 30 | > environment: 31 | > - OPENFGA_DATASTORE_ENGINE=postgres 32 | > - OPENFGA_DATASTORE_URI=postgres://postgres:password@postgres:5432/postgres?sslmode=disable 33 | > - OPENFGA_TRACE_ENABLED=true 34 | > - OPENFGA_TRACE_SAMPLE_RATIO=1 35 | > - OPENFGA_TRACE_OTLP_ENDPOINT=otel-collector:4317 36 | > - OPENFGA_METRICS_ENABLE_RPC_HISTOGRAMS=true 37 | 38 | ### Reproduction 39 | 40 | > Detail the steps taken to reproduce this error, what was expected, and whether this issue can be reproduced consistently or if it is intermittent. 41 | > 42 | > 1. Initialize OpenFgaClient with openfga_sdk.ClientConfiguration parameter api_host=127.0.0.1, credentials method client_credentials 43 | > 2. Invoke method read_authorization_models 44 | > 3. See exception thrown 45 | 46 | ### Sample Code the Produces Issues 47 | 48 | > 49 | > ``` 50 | > 51 | > ``` 52 | 53 | ### Backtrace (if applicable) 54 | 55 | > ``` 56 | > 57 | > ``` 58 | 59 | 60 | ### Expected behavior 61 | > A clear and concise description of what you expected to happen. 62 | 63 | ### Additional context 64 | > Add any other context about the problem here. 65 | -------------------------------------------------------------------------------- /tests/telemetry/configuration.test.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryConfiguration, TelemetryMetricConfig } from "../../telemetry/configuration"; 2 | import { TelemetryAttribute } from "../../telemetry/attributes"; 3 | import { TelemetryMetric } from "../../telemetry/metrics"; 4 | 5 | describe("TelemetryConfiguration", () => { 6 | test("should use defaults if not all metrics defined", () => { 7 | const config = new TelemetryConfiguration({ 8 | [TelemetryMetric.CounterCredentialsRequest]: {}, 9 | [TelemetryMetric.HistogramQueryDuration]: { 10 | attributes: new Set([ 11 | TelemetryAttribute.FgaClientRequestClientId, 12 | TelemetryAttribute.HttpResponseStatusCode, 13 | TelemetryAttribute.UrlScheme, 14 | TelemetryAttribute.HttpRequestMethod, 15 | ]) 16 | } 17 | }); 18 | 19 | expect(config.metrics?.counterCredentialsRequest?.attributes).toEqual(undefined); 20 | expect(config.metrics?.histogramQueryDuration?.attributes).toEqual(new Set( 21 | new Set([ 22 | TelemetryAttribute.FgaClientRequestClientId, 23 | TelemetryAttribute.HttpResponseStatusCode, 24 | TelemetryAttribute.UrlScheme, 25 | TelemetryAttribute.HttpRequestMethod, 26 | ]))); 27 | expect(config.metrics?.histogramRequestDuration?.attributes).toEqual(undefined); 28 | }); 29 | 30 | test("should use defaults", () => { 31 | const config = new TelemetryConfiguration(); 32 | 33 | expect(config.metrics?.counterCredentialsRequest?.attributes).toEqual(TelemetryConfiguration.defaultAttributes); 34 | expect(config.metrics?.histogramQueryDuration?.attributes).toEqual(TelemetryConfiguration.defaultAttributes); 35 | expect(config.metrics?.histogramRequestDuration?.attributes).toEqual(TelemetryConfiguration.defaultAttributes); 36 | }); 37 | 38 | test("should be undefined if empty object passed", () => { 39 | const config = new TelemetryConfiguration({}); 40 | 41 | expect(config.metrics?.counterCredentialsRequest?.attributes).toEqual(undefined); 42 | expect(config.metrics?.histogramQueryDuration?.attributes).toEqual(undefined); 43 | expect(config.metrics?.histogramRequestDuration?.attributes).toEqual(undefined); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /telemetry/metrics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | import { Counter, Histogram, Meter } from "@opentelemetry/api"; 15 | import { TelemetryCounter } from "./counters"; 16 | import { TelemetryHistogram } from "./histograms"; 17 | import { metrics } from "@opentelemetry/api"; 18 | 19 | export enum TelemetryMetric { 20 | CounterCredentialsRequest = "counterCredentialsRequest", 21 | HistogramRequestDuration = "histogramRequestDuration", 22 | HistogramQueryDuration = "histogramQueryDuration", 23 | } 24 | 25 | export class MetricRecorder { 26 | 27 | private _meter: Meter | null = null; 28 | private _counters: Record = {}; 29 | private _histograms: Record = {}; 30 | 31 | meter(): Meter { 32 | if (!this._meter) { 33 | this._meter = metrics.getMeter("@openfga/sdk", "0.6.3"); 34 | } 35 | return this._meter; 36 | } 37 | 38 | counter(counter: TelemetryCounter, value?: number, attributes?: Record): Counter { 39 | if (!this._counters[counter.name]) { 40 | this._counters[counter.name] = this.meter().createCounter(counter.name, { 41 | description: counter.description, 42 | unit: counter.unit, 43 | }); 44 | } 45 | 46 | if (value !== undefined) { 47 | this._counters[counter.name].add(value, attributes); 48 | } 49 | 50 | return this._counters[counter.name]; 51 | } 52 | 53 | histogram(histogram: TelemetryHistogram, value?: number, attributes?: Record): Histogram { 54 | if (!this._histograms[histogram.name]) { 55 | this._histograms[histogram.name] = this.meter().createHistogram(histogram.name, { 56 | description: histogram.description, 57 | unit: histogram.unit, 58 | }); 59 | } 60 | 61 | if (value !== undefined) { 62 | this._histograms[histogram.name].record(value, attributes); 63 | } 64 | 65 | return this._histograms[histogram.name]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /credentials/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | export enum CredentialsMethod { 15 | None = "none", 16 | ApiToken = "api_token", 17 | ClientCredentials = "client_credentials", 18 | } 19 | 20 | type BaseClientCredentialsConfig = { 21 | /** 22 | * Client ID 23 | * 24 | * @type {string} 25 | * @memberof Configuration 26 | */ 27 | 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} 44 | */ 45 | customClaims?: Record 46 | } 47 | 48 | export type ClientSecretConfig = BaseClientCredentialsConfig & { 49 | /** 50 | * Client Secret 51 | * 52 | * @type {string} 53 | * @memberof Configuration 54 | */ 55 | clientSecret: string; 56 | 57 | } 58 | export type PrivateKeyJWTConfig = BaseClientCredentialsConfig & { 59 | /** 60 | * Client assertion signing key 61 | * 62 | * @type {string} 63 | * @memberof Configuration 64 | */ 65 | clientAssertionSigningKey: string; 66 | /** 67 | * Client assertion signing algorithm, 68 | * defaults to `RS256` if not specified. 69 | * @type {string} 70 | * @memberof Configuration 71 | */ 72 | clientAssertionSigningAlgorithm?: string; 73 | } 74 | 75 | export type ClientCredentialsConfig = ClientSecretConfig | PrivateKeyJWTConfig; 76 | 77 | export interface ApiTokenConfig { 78 | /** 79 | * API Token Value 80 | * 81 | * @type {string} 82 | */ 83 | token: string; 84 | /** 85 | * API Token Header Name (default = Authorization) 86 | * 87 | * @type {string} 88 | */ 89 | headerName: string; 90 | /** 91 | * API Token Value Prefix (default = Bearer) 92 | * 93 | * @type {string} 94 | */ 95 | headerValuePrefix: string; 96 | } 97 | 98 | export type AuthCredentialsConfig = 99 | { 100 | method: CredentialsMethod.None | undefined; 101 | } | { 102 | method: CredentialsMethod.ApiToken; 103 | config: ApiTokenConfig; 104 | } | { 105 | method: CredentialsMethod.ClientCredentials; 106 | config: ClientCredentialsConfig; 107 | } | undefined; 108 | -------------------------------------------------------------------------------- /tests/helpers/default-config.ts: -------------------------------------------------------------------------------- 1 | import { ClientConfiguration, UserClientConfigurationParams } from "../../client"; 2 | import { CredentialsMethod } from "../../credentials"; 3 | 4 | export const OPENFGA_STORE_ID = "01H0H015178Y2V4CX10C2KGHF4"; 5 | export const OPENFGA_MODEL_ID = "01HWBBMZTT7F1M97DVXQK4Z7J3"; 6 | export const OPENFGA_API_URL = "https://api.fga.example"; 7 | export const OPENFGA_API_TOKEN_ISSUER = "tokenissuer.fga.example"; 8 | export const OPENFGA_API_AUDIENCE = "https://api.fga.example/"; 9 | export const OPENFGA_CLIENT_ID = "01H0H3D8TD07EWAQHXY9BWJG3V"; 10 | export const OPENFGA_CLIENT_SECRET = "this-is-very-secret"; 11 | export const OPENFGA_API_TOKEN = "fga_abcdef"; 12 | export const OPENFGA_CLIENT_ASSERTION_SIGNING_KEY = `-----BEGIN PRIVATE KEY----- 13 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmJ37Zi9/LS5/I 14 | E5pl7XobscHSFNrTfZC9Jx15KF5iJLFb9s8twQdo/hWPC4adidu7gVCIGNGYBGH2 15 | Q2z9nMrVA5TUQrrTsvJw0ldSWn2MeadZcMGI0AcaomOu8P7lxyaf/sgWAOgW1P+Y 16 | SAEPHHvKuA0orQVVWYwt7jaaQ0GBwEh3XiwqiwUKCJQ06eeQVxXxGr9DBYtZJOzn 17 | gLRj0wNF3WWU5JddV2o+CHRvpN1zLBHam3RXJQMdObs2waeR85AbfO6rNQr/Zscd 18 | Y6XDsHjeAHOykfoMBBexK0Rdu3Vqk2DSaXG3HUC54sbCLZSDmo4S0Dsax1IEWnWs 19 | rA8nD5O7AgMBAAECggEAWZzoNbFSJnhgEsmbNPO1t0HLq05Gc9FwwU2RGsMemM0b 20 | p6ieO3zsszM3VraQqBds0IHFxvAO78dJE1dmgQsDKNSXptwCnXoQDuC/ckfcmY0m 21 | nVsbZ/dDxNmUwaGBRht4TRSpeHPK6lTt3i+vBeC7zI9ERGG18WkH/TxC02a7g1aL 22 | emz/SNgOdFkHPoKcgYyUp2Svh0aly9g2NbyIusNO4C9M/tCYRobcrZBRIognNZKY 23 | bZVQrnilOClVcbND1oOPs0O6sxTMGd3eR7bS6w7i59vUCPwQSTo1L/FA23ZPY5kQ 24 | AgeGZnp4Nve1Ecsvp48MJHb4cwJeysxH6hhyl3zMHQKBgQDzKmo1Sa5wAqTD4HAP 25 | /aboYfSxhIE9c3qhj5HDP76PBZa8N5fyNyTJCsauk2a2/ZFOwCz142yF1JEbxHgb 26 | j6XYYazVFfk2DFWb9Ul/zQMmCVcETlRhxIQPc76f9QjvAc20B6xeR3M14RwfK/u+ 27 | FaN7PsMAItH0xJRpGIWpwN/3PQKBgQDyTUY2WsGNUzMKarLyKX5KSDELfgmJtcAv 28 | LunqhYnhks4i6PVknXIY4GuGhIhAanDFlFZIhTa5a2e2bNZvgRz+VxNNRsQQZPgt 29 | M9Gg1fLSqQOL7OZn+cjkkYfxNE1FLMoStaANl6JkCjN4Ted2pLbswCBXwa4qsxRZ 30 | bsA3BTWmVwKBgQCgqYSVAsLLZSPB+7dvCVPPNHF9HKRbmsIKnxZa3/IjAzlN0JmH 31 | QuH+Jy2QyPlTrIPmeVj7ebEJV6Isq4oEA8w7BIYyIBuRl2K08cMHOsh6yC8DPFHK 32 | axIqN3paq4akjBeCfJNpk2HO1pZDDkd9l0R1uMkUfO0mAQBh0/70YuhXrQKBgEbn 33 | igZZ5I3grOz9cEQhFE3UdlWwmkXsI8Mq7VStoz2ZYi0hEr5QvJS/B3gjzGNdQobu 34 | 85jhMrRr07u0ecPDeqKLBKD2dmV9xoojwdJZCWfQAbOurXX7yGfqlmdlML9vbeqv 35 | r5iKqQCxY4Ju+a7kYItDZbOIf9kK8oeBO0pegeadAoGAfYi3Sl3wYzaP44TKmjNq 36 | 3Z0NQChIcSzRDfEo25bioBzdlwf3tRBHTdPwdXhLJVTI/I90WMAcAgKaXlotrnMT 37 | HultzBviGb7LdUt1cNnjS9C+Cl8tCYePUx+Wg+pQruYX3fAo27G0GlIC8CIQz79M 38 | ElVV8gBIxYwuivacl3w9B6E= 39 | -----END PRIVATE KEY-----`; 40 | 41 | export const baseConfig: UserClientConfigurationParams = { 42 | storeId: OPENFGA_STORE_ID, 43 | authorizationModelId: OPENFGA_MODEL_ID, 44 | apiUrl: OPENFGA_API_URL, 45 | credentials: { 46 | method: CredentialsMethod.ClientCredentials, 47 | config: { 48 | apiTokenIssuer: OPENFGA_API_TOKEN_ISSUER, 49 | apiAudience: OPENFGA_API_AUDIENCE, 50 | clientId: OPENFGA_CLIENT_ID, 51 | clientSecret: OPENFGA_CLIENT_SECRET, 52 | } 53 | } 54 | }; 55 | 56 | export const defaultConfiguration = new ClientConfiguration(baseConfig); 57 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Publish 2 | 3 | on: 4 | merge_group: 5 | push: 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16, 18, 20] 19 | 20 | steps: 21 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up node 26 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | registry-url: "https://registry.npmjs.org" 30 | scope: "@openfga" 31 | always-auth: false 32 | cache: "npm" 33 | 34 | - name: Install dependencies 35 | run: npm ci 36 | 37 | - name: Build 38 | run: npm run build 39 | 40 | test: 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 45 | with: 46 | fetch-depth: 0 47 | 48 | - name: Set up node 49 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 50 | with: 51 | node-version: 20 52 | cache: "npm" 53 | 54 | - name: Install dependencies 55 | run: npm ci 56 | 57 | - name: Audit dependencies 58 | run: npm audit 59 | 60 | - name: Check for circular dependencies 61 | run: npx madge --circular . --extensions ts,js 62 | 63 | - name: Run tests 64 | run: npm test 65 | 66 | - name: Upload coverage to Codecov 67 | uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 68 | continue-on-error: true 69 | with: 70 | token: ${{ secrets.CODECOV_TOKEN }} 71 | slug: openfga/js-sdk 72 | 73 | publish: 74 | if: startsWith(github.ref, 'refs/tags/v') 75 | needs: [build, test] 76 | runs-on: ubuntu-latest 77 | 78 | permissions: 79 | contents: read 80 | id-token: write 81 | 82 | steps: 83 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 84 | with: 85 | fetch-depth: 0 86 | 87 | - name: Set up node 88 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 89 | with: 90 | node-version: 20 91 | registry-url: "https://registry.npmjs.org" 92 | scope: "@openfga" 93 | always-auth: false 94 | cache: "npm" 95 | 96 | - name: Install dependencies 97 | run: npm ci 98 | 99 | - name: Publish to npm 100 | run: npm publish 101 | env: 102 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 103 | 104 | create-release: 105 | if: startsWith(github.ref, 'refs/tags/v') 106 | needs: [publish] 107 | runs-on: ubuntu-latest 108 | 109 | permissions: 110 | contents: write 111 | 112 | steps: 113 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 114 | with: 115 | fetch-depth: 0 116 | 117 | - uses: Roang-zero1/github-create-release-action@57eb9bdce7a964e48788b9e78b5ac766cb684803 # v3.0.1 118 | with: 119 | version_regex: ^v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+ 120 | env: 121 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 122 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '16 11 * * 0' 14 | push: 15 | branches: [ "main" ] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. 25 | if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' 26 | permissions: 27 | # Needed to upload the results to code-scanning dashboard. 28 | security-events: write 29 | # Needed to publish results and get a badge (see publish_results below). 30 | id-token: write 31 | # Uncomment the permissions below if installing in a private repository. 32 | # contents: read 33 | # actions: read 34 | 35 | steps: 36 | - name: "Checkout code" 37 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 38 | with: 39 | persist-credentials: false 40 | 41 | - name: "Run analysis" 42 | uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 43 | with: 44 | results_file: results.sarif 45 | results_format: sarif 46 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 47 | # - you want to enable the Branch-Protection check on a *public* repository, or 48 | # - you are installing Scorecard on a *private* repository 49 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 50 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 51 | 52 | # Public repositories: 53 | # - Publish results to OpenSSF REST API for easy access by consumers 54 | # - Allows the repository to include the Scorecard badge. 55 | # - See https://github.com/ossf/scorecard-action#publishing-results. 56 | # For private repositories: 57 | # - `publish_results` will always be set to `false`, regardless 58 | # of the value entered here. 59 | publish_results: true 60 | 61 | # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore 62 | # file_mode: git 63 | 64 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 65 | # format to the repository Actions tab. 66 | - name: "Upload artifact" 67 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 68 | with: 69 | name: SARIF file 70 | path: results.sarif 71 | retention-days: 5 72 | 73 | # Upload the results to GitHub's code scanning dashboard (optional). 74 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 75 | - name: "Upload to code-scanning" 76 | uses: github/codeql-action/upload-sarif@7273f08caa1dcf2c2837f362f1982de0ab4dc344 #v2.22.3 77 | with: 78 | sarif_file: results.sarif 79 | -------------------------------------------------------------------------------- /.github/SECURITY-INSIGHTS.yml: -------------------------------------------------------------------------------- 1 | # Security Insights 2.0 file https://github.com/ossf/security-insights 2 | # Specification: https://github.com/ossf/security-insights/tree/main/spec 3 | 4 | header: 5 | schema-version: 2.0.0 6 | last-updated: '2025-09-18' 7 | last-reviewed: '2025-09-18' 8 | url: https://github.com/openfga/js-sdk 9 | project-si-source: https://raw.githubusercontent.com/openfga/.github/main/SECURITY-INSIGHTS.yml 10 | comment: OpenFGA SDK for Node.js and JavaScript. 11 | 12 | repository: 13 | url: https://github.com/openfga/js-sdk 14 | status: active 15 | bug-fixes-only: false 16 | accepts-change-request: true 17 | accepts-automated-change-request: true 18 | no-third-party-packages: false 19 | core-team: 20 | - name: Raghd Hamzeh 21 | affiliation: Okta 22 | email: raghd.hamzeh@okta.com 23 | social: https://github.com/rhamzeh 24 | primary: true 25 | - name: Adrian Tam 26 | affiliation: Okta 27 | email: adrian.tam@okta.com 28 | social: https://github.com/adriantam 29 | - name: Ewan Harris 30 | affiliation: Okta 31 | email: ewan.harris@okta.com 32 | social: https://github.com/ewanharris 33 | 34 | license: 35 | url: https://raw.githubusercontent.com/openfga/js-sdk/main/LICENSE 36 | expression: Apache-2.0 37 | release: 38 | changelog: https://github.com/openfga/js-sdk/releases 39 | automated-pipeline: true 40 | distribution-points: 41 | - uri: https://github.com/openfga/js-sdk/releases 42 | comment: GitHub Release Page 43 | 44 | documentation: 45 | contributing-guide: https://github.com/openfga/.github/blob/main/CONTRIBUTING.md 46 | dependency-management-policy: https://github.com/openfga/openfga/blob/main/docs/dependencies-policy.md 47 | governance: https://github.com/openfga/.github/blob/main/GOVERNANCE.md 48 | review-policy: https://github.com/openfga/.github/blob/main/CONTRIBUTING.md 49 | security-policy: https://github.com/openfga/js-sdk/SECURITY.md 50 | 51 | security: 52 | assessments: 53 | self: 54 | evidence: https://github.com/cncf/tag-security/blob/main/community/assessments/projects/openfga/joint-assessment.md 55 | date: '2024-12-19' 56 | comment: OpenFGA has completed a CNCF security joint assessment with CNCF TAG-Security 57 | 58 | champions: 59 | - name: Ewan Harris 60 | email: ewan.harris@okta.com 61 | primary: true 62 | tools: 63 | - name: Dependabot 64 | type: SCA 65 | version: latest 66 | rulesets: 67 | - built-in 68 | integration: 69 | adhoc: false 70 | ci: true 71 | release: true 72 | comment: Dependabot is enabled for this repository to automatically update dependencies. 73 | - name: Snyk 74 | type: SCA 75 | version: latest 76 | rulesets: 77 | - built-in 78 | integration: 79 | adhoc: false 80 | ci: true 81 | release: true 82 | comment: Snyk is enabled for this repository to scan for vulnerabilities. 83 | - name: Socket 84 | type: SCA 85 | version: latest 86 | rulesets: 87 | - built-in 88 | integration: 89 | adhoc: false 90 | ci: true 91 | release: true 92 | comment: Socket is enabled for this repo to scan for supply chain security vulnerabilities. 93 | - name: OSSF Scorecard 94 | type: SCA 95 | version: latest 96 | rulesets: 97 | - built-in 98 | integration: 99 | adhoc: false 100 | ci: true 101 | release: true 102 | comment: OSSF Scorecard is enabled for this repository 103 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐛 Report a bug 2 | description: Have you found a bug or issue? Create a bug report for OpenFGA 3 | labels: [ "bug" ] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | 11 | - type: markdown 12 | attributes: 13 | value: | 14 | **Please do not report security vulnerabilities here**. Use https://github.com/openfga/js-sdk/security/advisories/new or send us an email at security@openfga.dev instead. 15 | 16 | - type: checkboxes 17 | id: checklist 18 | attributes: 19 | label: Checklist 20 | options: 21 | - label: I have looked into the [README](https://github.com/openfga/js-sdk/blob/main/README.md) and have not found a suitable solution or answer. 22 | required: true 23 | - label: I have looked into the [documentation](https://openfga.dev/docs) and have not found a suitable solution or answer. 24 | required: true 25 | - label: I have searched the [issues](https://github.com/openfga/js-sdk/issues) and have not found a suitable solution or answer. 26 | required: true 27 | - label: I have upgraded to the [latest version](https://github.com/openfga/js-sdk/releases/latest) of OpenFGA and the issue still persists. 28 | required: true 29 | - label: I have searched the [Slack community](https://openfga.dev/community) and have not found a suitable solution or answer. 30 | required: true 31 | - label: I agree to the terms within the [OpenFGA Code of Conduct](https://github.com/openfga/.github/blob/main/CODE_OF_CONDUCT.md). 32 | required: true 33 | 34 | - type: textarea 35 | id: description 36 | attributes: 37 | label: Description 38 | description: Provide a clear and concise description of the issue. 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: expectation 44 | attributes: 45 | label: Expectation 46 | description: Tell us about the behavior you expected to see. 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: reproduction 52 | attributes: 53 | label: Reproduction 54 | description: Detail the steps taken to reproduce this error and, ideally, share a repo of a minimal reproducible example. State whether this issue can be reproduced consistently or if it is intermittent. 55 | placeholder: | 56 | 1. Given... 57 | 2. When... 58 | 3. Then... 59 | validations: 60 | required: true 61 | 62 | - type: input 63 | id: environment-sdk-version 64 | attributes: 65 | label: OpenFGA SDK version 66 | description: The version of JavaScript and Node.js SDK for OpenFGA you're using. 67 | validations: 68 | required: true 69 | 70 | - type: input 71 | id: environment-openfga-version 72 | attributes: 73 | label: OpenFGA version 74 | description: The version of OpenFGA you're using. 75 | validations: 76 | required: true 77 | 78 | - type: input 79 | id: environment-sdk-config 80 | attributes: 81 | label: SDK Configuration 82 | description: How are you initializing the SDK (DO NOT SHARE ANY SECRETS) 83 | validations: 84 | required: true 85 | 86 | - type: textarea 87 | id: logs 88 | attributes: 89 | label: Logs 90 | description: Do you have any logs or traces that could help us debug the problem? 91 | validations: 92 | required: false 93 | 94 | - type: textarea 95 | id: references 96 | attributes: 97 | label: References 98 | description: Any references to other issues, PRs, documentation or other links 99 | validations: 100 | required: false 101 | 102 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to OpenFGA projects 2 | 3 | A big welcome and thank you for considering contributing to the OpenFGA open source projects. It’s people like you that make it a reality for users in our community. 4 | 5 | Reading and following these guidelines will help us make the contribution process easy and effective for everyone involved. It also communicates that you agree to respect the time of the developers managing and developing these open source projects. In return, we will reciprocate that respect by addressing your issue, assessing changes, and helping you finalize your pull requests. 6 | 7 | ### Table of Contents 8 | 9 | * [Code of Conduct](#code-of-conduct) 10 | * [Getting Started](#getting-started) 11 | * [Making Changes](#making-changes) 12 | * [Opening Issues](#opening-issues) 13 | * [Submitting Pull Requests](#submitting-pull-requests) 14 | * [Getting in Touch](#getting-in-touch) 15 | * [Have a question or problem?](#have-a-question-or-problem) 16 | * [Vulnerability Reporting](#vulnerability-reporting) 17 | 18 | ## Code of Conduct 19 | 20 | By participating and contributing to this project, you are expected to uphold our [Code of Conduct](https://github.com/openfga/.github/blob/main/CODE_OF_CONDUCT.md). 21 | 22 | ## Getting Started 23 | 24 | ### Making Changes 25 | 26 | When contributing to a repository, the first step is to open [an issue](https://github.com/openfga/js-sdk/issues) to discuss the change you wish to make before making them. 27 | 28 | ### Opening Issues 29 | 30 | Before you submit a new issue please make sure to search all open and closed issues. It is possible your feature request/issue has already been answered. Make sure to also check the [OpenFGA discussions](https://github.com/orgs/openfga/discussions). 31 | 32 | The repo includes an issue template that will walk through all the places to check before submitting your issue here. Please follow the instructions there to make sure this is not a duplicate issue and that we have everything we need to research and reproduce this problem. 33 | 34 | If you have found a bug or if you have a feature request, please report them in the [repo issues](https://github.com/openfga/js-sdk/issues). Cross-SDK bugs and feature requests can be additionally reported in the [sdk-generator repo](https://github.com/openfga/sdk-generator/issues) issues section, where the individual SDK issue is then linked. 35 | 36 | **Please do not report security vulnerabilities on the public GitHub issue tracker.** 37 | 38 | ### Submitting Pull Requests 39 | 40 | Feel free to submit a Pull Request against this repository. Please make sure to follow the existing code style and include tests where applicable. 41 | 42 | Some files in this repository are autogenerated. These files have a comment at the top indicating that they are autogenerated and should not be modified directly - the files are usually identified by a header marking them as such, or by their inclusion in [`.openapi-generator/FILES`](./.openapi-generator/FILES). Changes to these files should be made in the [sdk-generator](https://github.com/openfga/sdk-generator) repository in tandem, so please consider additionally submitting your Pull Requests to the [sdk-generator](https://github.com/openfga/sdk-generator) and linking the two PRs together and to the corresponding issue. This will greatly assist the OpenFGA team in being able to give timely reviews as well as deploying fixes and updates to our other SDKs as well. 43 | 44 | ## Getting in touch 45 | 46 | ### Have a question or problem? 47 | 48 | Please do not open issues for general support or usage questions. Instead, join us over in the [OpenFGA discussions](https://github.com/orgs/openfga/discussions) or [support community](https://openfga.dev/community). 49 | 50 | ### Vulnerability Reporting 51 | 52 | Please do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://github.com/openfga/.github/blob/main/SECURITY.md) details the procedure for disclosing security issues. 53 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | /** 15 | * Version of the OpenFGA JavaScript SDK. 16 | */ 17 | const SdkVersion = "0.9.1"; 18 | 19 | /** 20 | * User agent used in HTTP requests. 21 | */ 22 | const UserAgent = "openfga-sdk js/0.9.1"; 23 | 24 | /** 25 | * Example API domain for documentation/tests. 26 | */ 27 | const SampleBaseDomain = "fga.example"; 28 | 29 | /** 30 | * API URL used for tests. 31 | */ 32 | const TestApiUrl = `https://api.${SampleBaseDomain}`; 33 | 34 | /** 35 | * API Token Issuer URL used for tests. 36 | */ 37 | const TestIssuerUrl = `https://issuer.${SampleBaseDomain}`; 38 | 39 | /** 40 | * Default API URL. 41 | */ 42 | const DefaultApiUrl = "http://localhost:8080"; 43 | 44 | // Retry configuration 45 | 46 | /** 47 | * Maximum allowed number of retries for HTTP requests. 48 | */ 49 | const RetryMaxAllowedNumber = 15; 50 | 51 | /** 52 | * Default maximum number of retries for HTTP requests. 53 | */ 54 | const DefaultMaxRetry = 3; 55 | 56 | /** 57 | * Default minimum wait time between retries in milliseconds. 58 | */ 59 | const DefaultMinWaitInMs = 100; 60 | 61 | /** 62 | * Maximum backoff time in seconds. 63 | */ 64 | const MaxBackoffTimeInSec = 120; 65 | 66 | /** 67 | * Maximum allowable duration for retry headers in seconds. 68 | */ 69 | const RetryHeaderMaxAllowableDurationInSec = 1800; 70 | 71 | /** 72 | * Standard HTTP header for retry-after. 73 | */ 74 | const RetryAfterHeaderName = "Retry-After"; 75 | 76 | /** 77 | * Rate limit reset header name. 78 | */ 79 | const RateLimitResetHeaderName = "X-RateLimit-Reset"; 80 | 81 | /** 82 | * Alternative rate limit reset header name. 83 | */ 84 | const RateLimitResetAltHeaderName = "X-Rate-Limit-Reset"; 85 | 86 | // Client methods 87 | 88 | /** 89 | * Maximum number of parallel requests for a single method. 90 | */ 91 | const ClientMaxMethodParallelRequests = 10; 92 | 93 | /** 94 | * Maximum batch size for batch requests. 95 | */ 96 | const ClientMaxBatchSize = 50; 97 | 98 | /** 99 | * Header used to identify the client method. 100 | */ 101 | const ClientMethodHeader = "X-OpenFGA-Client-Method"; 102 | 103 | /** 104 | * Header used to identify bulk requests. 105 | */ 106 | const ClientBulkRequestIdHeader = "X-OpenFGA-Client-Bulk-Request-Id"; 107 | 108 | // Connection options 109 | 110 | /** 111 | * Default timeout for HTTP requests in milliseconds. 112 | */ 113 | const DefaultRequestTimeoutInMs = 10000; 114 | 115 | /** 116 | * Default connection timeout in milliseconds. 117 | */ 118 | const DefaultConnectionTimeoutInMs = 10000; 119 | 120 | // Token management 121 | 122 | /** 123 | * Buffer time in seconds before token expiry to consider it expired. 124 | */ 125 | const TokenExpiryThresholdBufferInSec = 300; 126 | 127 | /** 128 | * Jitter time in seconds to add randomness to token expiry checks. 129 | */ 130 | const TokenExpiryJitterInSec = 300; 131 | 132 | // FGA Response Headers 133 | 134 | /** 135 | * Response header name for query duration in milliseconds. 136 | */ 137 | const QueryDurationHeaderName = "fga-query-duration-ms"; 138 | 139 | const SdkConstants = Object.freeze({ 140 | SdkVersion, 141 | UserAgent, 142 | SampleBaseDomain, 143 | TestApiUrl, 144 | TestIssuerUrl, 145 | DefaultApiUrl, 146 | RetryMaxAllowedNumber, 147 | DefaultMaxRetry, 148 | DefaultMinWaitInMs, 149 | MaxBackoffTimeInSec, 150 | RetryHeaderMaxAllowableDurationInSec, 151 | RetryAfterHeaderName, 152 | RateLimitResetHeaderName, 153 | RateLimitResetAltHeaderName, 154 | ClientMaxMethodParallelRequests, 155 | ClientMaxBatchSize, 156 | ClientMethodHeader, 157 | ClientBulkRequestIdHeader, 158 | DefaultRequestTimeoutInMs, 159 | DefaultConnectionTimeoutInMs, 160 | TokenExpiryThresholdBufferInSec, 161 | TokenExpiryJitterInSec, 162 | QueryDurationHeaderName, 163 | }); 164 | 165 | export default SdkConstants; -------------------------------------------------------------------------------- /example/opentelemetry/opentelemetry.mjs: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TelemetryAttribute, TelemetryConfiguration, TelemetryMetric } from "@openfga/sdk"; 3 | 4 | let credentials; 5 | if (process.env.FGA_CLIENT_ID) { 6 | credentials = { 7 | method: CredentialsMethod.ClientCredentials, 8 | config: { 9 | clientId: process.env.FGA_CLIENT_ID, 10 | clientSecret: process.env.FGA_CLIENT_SECRET, 11 | apiAudience: process.env.FGA_API_AUDIENCE, 12 | apiTokenIssuer: process.env.FGA_API_TOKEN_ISSUER 13 | } 14 | }; 15 | } 16 | 17 | const telemetryConfig = { 18 | metrics: { 19 | [TelemetryMetric.CounterCredentialsRequest]: { 20 | attributes: new Set([ 21 | TelemetryAttribute.UrlScheme, 22 | TelemetryAttribute.UserAgentOriginal, 23 | TelemetryAttribute.HttpRequestMethod, 24 | TelemetryAttribute.FgaClientRequestClientId, 25 | TelemetryAttribute.FgaClientRequestStoreId, 26 | TelemetryAttribute.FgaClientRequestModelId, 27 | TelemetryAttribute.HttpRequestResendCount, 28 | ]) 29 | }, 30 | [TelemetryMetric.HistogramRequestDuration]: { 31 | attributes: new Set([ 32 | TelemetryAttribute.HttpResponseStatusCode, 33 | TelemetryAttribute.UserAgentOriginal, 34 | TelemetryAttribute.FgaClientRequestMethod, 35 | TelemetryAttribute.FgaClientRequestClientId, 36 | TelemetryAttribute.FgaClientRequestStoreId, 37 | TelemetryAttribute.FgaClientRequestModelId, 38 | TelemetryAttribute.HttpRequestResendCount, 39 | ]) 40 | }, 41 | [TelemetryMetric.HistogramQueryDuration]: { 42 | attributes: new Set([ 43 | TelemetryAttribute.FgaClientRequestBatchCheckSize, 44 | TelemetryAttribute.HttpResponseStatusCode, 45 | TelemetryAttribute.UserAgentOriginal, 46 | TelemetryAttribute.FgaClientRequestMethod, 47 | TelemetryAttribute.FgaClientRequestClientId, 48 | TelemetryAttribute.FgaClientRequestStoreId, 49 | TelemetryAttribute.FgaClientRequestModelId, 50 | TelemetryAttribute.HttpRequestResendCount, 51 | ]) 52 | } 53 | } 54 | }; 55 | 56 | const fgaClient = new OpenFgaClient({ 57 | apiUrl: process.env.FGA_API_URL, 58 | storeId: process.env.FGA_STORE_ID, 59 | authorizationModelId: process.env.FGA_MODEL_ID, 60 | credentials, 61 | telemetry: telemetryConfig, 62 | }); 63 | 64 | async function main () { 65 | 66 | setTimeout(async () => { 67 | try { 68 | await main(); 69 | } catch (error) { 70 | console.log(error); 71 | } 72 | }, 20000); 73 | 74 | console.log("Reading Authorization Models"); 75 | const { authorization_models } = await fgaClient.readAuthorizationModels(); 76 | console.log(`Models Count: ${authorization_models.length}`); 77 | 78 | console.log("Reading Tuples"); 79 | const { tuples } = await fgaClient.read(); 80 | console.log(`Tuples count: ${tuples.length}`); 81 | 82 | 83 | const checkRequests = Math.floor(Math.random() * 10); 84 | console.log(`Making ${checkRequests} checks`); 85 | for (let index = 0; index < checkRequests; index++) { 86 | console.log("Checking for access" + index); 87 | try { 88 | const { allowed } = await fgaClient.check({ 89 | user: "user:anne", 90 | relation: "owner", 91 | object: "folder:foo" 92 | }); 93 | console.log(`Allowed: ${allowed}`); 94 | } catch (error) { 95 | if (error instanceof FgaApiValidationError) { 96 | console.log(`Failed due to ${error.apiErrorMessage}`); 97 | } else { 98 | throw error; 99 | } 100 | } 101 | } 102 | 103 | console.log("Calling BatcCheck") 104 | await fgaClient.batchCheck({ 105 | checks: [ 106 | { 107 | object: "doc:roadmap", 108 | relation: "can_read", 109 | user: "user:anne", 110 | }, 111 | { 112 | object: "doc:roadmap", 113 | relation: "can_read", 114 | user: "user:dan", 115 | }, 116 | { 117 | object: "doc:finances", 118 | relation: "can_read", 119 | user: "user:dan" 120 | }, 121 | { 122 | object: "doc:finances", 123 | relation: "can_reads", 124 | user: "user:anne", 125 | } 126 | ] 127 | }, { 128 | authorizationModelId: "01JC6KPJ0CKSZ69C5Z26CYWX2N" 129 | }); 130 | 131 | console.log("writing tuple"); 132 | await fgaClient.write({ 133 | writes: [ 134 | { 135 | user: "user:anne", 136 | relation: "owner", 137 | object: "folder:date-"+Date.now(), 138 | } 139 | ] 140 | }); 141 | } 142 | 143 | 144 | main() 145 | .catch(error => { 146 | console.error(`error: ${error}`); 147 | process.exitCode = 1; 148 | }); 149 | -------------------------------------------------------------------------------- /telemetry/attributes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | import { URL } from "url"; 15 | 16 | export enum TelemetryAttribute { 17 | FgaClientRequestClientId = "fga-client.request.client_id", 18 | FgaClientRequestMethod = "fga-client.request.method", 19 | FgaClientRequestModelId = "fga-client.request.model_id", 20 | FgaClientRequestStoreId = "fga-client.request.store_id", 21 | FgaClientResponseModelId = "fga-client.response.model_id", 22 | FgaClientUser = "fga-client.user", 23 | HttpClientRequestDuration = "http.client.request.duration", 24 | FgaClientRequestBatchCheckSize = "fga-client.request.batch_check_size", 25 | HttpHost = "http.host", 26 | HttpRequestMethod = "http.request.method", 27 | HttpRequestResendCount = "http.request.resend_count", 28 | HttpResponseStatusCode = "http.response.status_code", 29 | HttpServerRequestDuration = "http.server.request.duration", 30 | UrlScheme = "url.scheme", 31 | UrlFull = "url.full", 32 | UserAgentOriginal = "user_agent.original", 33 | } 34 | 35 | export class TelemetryAttributes { 36 | static prepare( 37 | attributes?: Record, 38 | filter?: Set 39 | ): Record { 40 | attributes = attributes || {}; 41 | // ensure we are always using a set 42 | filter = new Set(filter) || new Set(); 43 | const result: Record = {}; 44 | 45 | for (const key in attributes) { 46 | if (filter.has(key as TelemetryAttribute)) { 47 | result[key] = attributes[key]; 48 | } 49 | } 50 | 51 | return result; 52 | } 53 | 54 | static fromRequest({ 55 | userAgent, 56 | fgaMethod, 57 | httpMethod, 58 | url, 59 | resendCount, 60 | start, 61 | credentials, 62 | attributes = {}, 63 | }: { 64 | userAgent?: string; 65 | fgaMethod?: string; 66 | httpMethod?: string; 67 | url?: string; 68 | resendCount?: number; 69 | start?: number; 70 | credentials?: any; 71 | attributes?: Record; 72 | }): Record { 73 | if (fgaMethod) attributes[TelemetryAttribute.FgaClientRequestMethod] = fgaMethod; 74 | if (userAgent) attributes[TelemetryAttribute.UserAgentOriginal] = userAgent; 75 | if (httpMethod) attributes[TelemetryAttribute.HttpRequestMethod] = httpMethod; 76 | 77 | if (url) { 78 | const parsedUrl = new URL(url); 79 | attributes[TelemetryAttribute.HttpHost] = parsedUrl.hostname; 80 | attributes[TelemetryAttribute.UrlScheme] = parsedUrl.protocol; 81 | attributes[TelemetryAttribute.UrlFull] = url; 82 | } 83 | 84 | if (start) attributes[TelemetryAttribute.HttpClientRequestDuration] = Math.round(performance.now() - start); 85 | if (resendCount) attributes[TelemetryAttribute.HttpRequestResendCount] = resendCount; 86 | if (credentials && credentials.method === "client_credentials") { 87 | attributes[TelemetryAttribute.FgaClientRequestClientId] = credentials.configuration.clientId; 88 | } 89 | 90 | return attributes; 91 | } 92 | 93 | static fromResponse({ 94 | response, 95 | attributes = {}, 96 | }: { 97 | response: any; 98 | attributes?: Record; 99 | }): Record { 100 | if (response?.status) attributes[TelemetryAttribute.HttpResponseStatusCode] = response.status; 101 | 102 | const responseHeaders = response?.headers || {}; 103 | const responseModelId = responseHeaders["openfga-authorization-model-id"]; 104 | const responseQueryDuration = responseHeaders["fga-query-duration-ms"] ? parseInt(responseHeaders["fga-query-duration-ms"], 10) : undefined; 105 | 106 | if (responseModelId) { 107 | attributes[TelemetryAttribute.FgaClientResponseModelId] = responseModelId; 108 | } 109 | if (typeof responseQueryDuration !== "undefined" && Number.isFinite(responseQueryDuration)) { 110 | attributes[TelemetryAttribute.HttpServerRequestDuration] = responseQueryDuration as number; 111 | } 112 | 113 | return attributes; 114 | } 115 | 116 | static fromRequestBody(body: any, attributes: Record = {}): Record { 117 | if (body?.authorization_model_id) { 118 | attributes[TelemetryAttribute.FgaClientRequestModelId] = body.authorization_model_id; 119 | } 120 | 121 | if (body?.tuple_key?.user) { 122 | attributes[TelemetryAttribute.FgaClientUser] = body.tuple_key.user; 123 | } 124 | 125 | if (body?.checks?.length) { 126 | attributes[TelemetryAttribute.FgaClientRequestBatchCheckSize] = body.checks.length; 127 | } 128 | return attributes; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/telemetry/attributes.test.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryAttribute, TelemetryAttributes } from "../../telemetry/attributes"; 2 | 3 | describe("TelemetryAttributes", () => { 4 | 5 | test("should prepare attributes correctly", () => { 6 | const attributes = { 7 | "fga-client.request.client_id": "test-client-id", 8 | "http.host": "example.com", 9 | }; 10 | 11 | const filter = new Set([TelemetryAttribute.FgaClientRequestClientId]); 12 | const prepared = TelemetryAttributes.prepare(attributes, filter); 13 | 14 | expect(prepared).toEqual({ "fga-client.request.client_id": "test-client-id" }); 15 | }); 16 | 17 | test("should return an empty object when attributes is provided but filter is undefined", () => { 18 | const attributes = { 19 | [TelemetryAttribute.HttpHost]: "example.com", 20 | [TelemetryAttribute.HttpResponseStatusCode]: 200, 21 | }; 22 | expect(TelemetryAttributes.prepare(attributes)).toEqual({}); 23 | }); 24 | 25 | test("should return an empty object when filter is provided but attributes is undefined", () => { 26 | const filter = new Set([ 27 | TelemetryAttribute.HttpHost, 28 | ]); 29 | expect(TelemetryAttributes.prepare(undefined, filter)).toEqual({}); 30 | }); 31 | 32 | test("should return an empty object when none of the attributes are in the filter set", () => { 33 | const attributes = { 34 | [TelemetryAttribute.HttpHost]: "example.com", 35 | [TelemetryAttribute.HttpResponseStatusCode]: 200, 36 | }; 37 | const filter = new Set([ 38 | TelemetryAttribute.UserAgentOriginal, 39 | ]); 40 | expect(TelemetryAttributes.prepare(attributes, filter)).toEqual({}); 41 | }); 42 | 43 | test("should create attributes from request correctly", () => { 44 | const result = TelemetryAttributes.fromRequest({ 45 | userAgent: "Mozilla/5.0", 46 | fgaMethod: "GET", 47 | httpMethod: "POST", 48 | url: "https://example.com", 49 | resendCount: 2, 50 | start: 1000, 51 | credentials: { method: "client_credentials", configuration: { clientId: "client-id" } }, 52 | }); 53 | 54 | expect(result["user_agent.original"]).toEqual("Mozilla/5.0"); 55 | expect(result["fga-client.request.method"]).toEqual("GET"); 56 | expect(result["http.request.method"]).toEqual("POST"); 57 | expect(result["http.host"]).toEqual("example.com"); 58 | expect(result["url.scheme"]).toEqual("https:"); 59 | }); 60 | 61 | test("should create attributes from response correctly", () => { 62 | const response = { status: 200, headers: { "openfga-authorization-model-id": "model-id", "fga-query-duration-ms": "10" } }; 63 | const result = TelemetryAttributes.fromResponse({ response }); 64 | 65 | // Verify line 90 is covered - status is correctly set 66 | expect(result["http.response.status_code"]).toEqual(200); 67 | expect(result["fga-client.response.model_id"]).toEqual("model-id"); 68 | expect(result["http.server.request.duration"]).toEqual(10); 69 | }); 70 | 71 | test("should handle response without status correctly", () => { 72 | const response = { headers: { "openfga-authorization-model-id": "model-id", "fga-query-duration-ms": "10" } }; 73 | const result = TelemetryAttributes.fromResponse({ response }); 74 | 75 | // Verify that no status code is set when response does not have a status 76 | expect(result["http.response.status_code"]).toBeUndefined(); 77 | expect(result["fga-client.response.model_id"]).toEqual("model-id"); 78 | expect(result["http.server.request.duration"]).toEqual(10); 79 | }); 80 | 81 | test("should create attributes from a request body correctly", () => { 82 | const body = { authorization_model_id: "model-id", tuple_key: { user: "user:anne" } }; 83 | const attributes = TelemetryAttributes.fromRequestBody(body); 84 | 85 | expect(attributes[TelemetryAttribute.FgaClientRequestModelId]).toEqual("model-id"); 86 | expect(attributes[TelemetryAttribute.FgaClientUser]).toEqual("user:anne"); 87 | }); 88 | 89 | test("should create attributes from a request body without tuple_key", () => { 90 | const body = { authorization_model_id: "model-id" }; 91 | const attributes = TelemetryAttributes.fromRequestBody(body); 92 | 93 | expect(attributes[TelemetryAttribute.FgaClientRequestModelId]).toEqual("model-id"); 94 | expect(attributes[TelemetryAttribute.FgaClientUser]).toBeUndefined(); 95 | }); 96 | 97 | 98 | test("should create attributes from a batchCheck request body correctly", () => { 99 | const body = { 100 | authorization_model_id: "model-id", 101 | checks: [ 102 | { 103 | tuple_key: { 104 | user: "user:anne", 105 | object: "doc:123", 106 | relation: "can_view" 107 | } 108 | }, 109 | { 110 | tuple_key: { 111 | user: "user:anne", 112 | object: "doc:789", 113 | relation: "can_view" 114 | } 115 | } 116 | ] 117 | }; 118 | const attributes = TelemetryAttributes.fromRequestBody(body); 119 | 120 | expect(attributes[TelemetryAttribute.FgaClientRequestModelId]).toEqual("model-id"); 121 | expect(attributes[TelemetryAttribute.FgaClientRequestBatchCheckSize]).toEqual(2); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /configuration.ts: -------------------------------------------------------------------------------- 1 | import { ApiTokenConfig, AuthCredentialsConfig, ClientCredentialsConfig, CredentialsMethod } from "./credentials/types"; 2 | import { FgaValidationError, } from "./errors"; 3 | import { assertParamExists, isWellFormedUriString } from "./validation"; 4 | import { TelemetryConfig, TelemetryConfiguration } from "./telemetry/configuration"; 5 | import SdkConstants from "./constants"; 6 | 7 | // default maximum number of retry 8 | const DEFAULT_MAX_RETRY = SdkConstants.DefaultMaxRetry; 9 | 10 | // default minimum wait period in retry - but will backoff exponentially 11 | const DEFAULT_MIN_WAIT_MS = SdkConstants.DefaultMinWaitInMs; 12 | 13 | const DEFAULT_USER_AGENT = SdkConstants.UserAgent; 14 | 15 | export interface RetryParams { 16 | maxRetry?: number; 17 | minWaitInMs?: number; 18 | } 19 | 20 | export interface UserConfigurationParams { 21 | apiUrl?: string; 22 | /** 23 | * @deprecated Replace usage of `apiScheme` + `apiHost` with `apiUrl` 24 | */ 25 | apiScheme?: string; 26 | /** 27 | * @deprecated Replace usage of `apiScheme` + `apiHost` with `apiUrl` 28 | */ 29 | apiHost?: string; 30 | credentials?: CredentialsConfig; 31 | baseOptions?: any; 32 | retryParams?: RetryParams; 33 | telemetry?: TelemetryConfig; 34 | } 35 | 36 | export function GetDefaultRetryParams (maxRetry: number = DEFAULT_MAX_RETRY, minWaitInMs: number = DEFAULT_MIN_WAIT_MS) { 37 | return { 38 | maxRetry: maxRetry, 39 | minWaitInMs: minWaitInMs, 40 | }; 41 | } 42 | 43 | interface BaseOptions { 44 | headers: Record; 45 | } 46 | 47 | type CredentialsConfig = 48 | { 49 | method: CredentialsMethod.None | undefined; 50 | } | { 51 | method: CredentialsMethod.ApiToken; 52 | config: Pick; 53 | } | { 54 | method: CredentialsMethod.ClientCredentials; 55 | config: ClientCredentialsConfig; 56 | } | undefined; 57 | 58 | export class Configuration { 59 | /** 60 | * Defines the version of the SDK 61 | * 62 | * @private 63 | * @type {string} 64 | * @memberof Configuration 65 | */ 66 | private static sdkVersion = SdkConstants.SdkVersion; 67 | 68 | /** 69 | * provide the full api URL (e.g. `https://api.fga.example`) 70 | * 71 | * @type {string} 72 | * @memberof Configuration 73 | */ 74 | apiUrl: string; 75 | 76 | /** 77 | * provide scheme (e.g. `https`) 78 | * 79 | * @type {string} 80 | * @memberof Configuration 81 | * @deprecated 82 | */ 83 | apiScheme = "https"; 84 | /** 85 | * provide server host (e.g. `api.fga.example`) 86 | * 87 | * @type {string} 88 | * @memberof Configuration 89 | * @deprecated 90 | */ 91 | apiHost: string; 92 | /** 93 | * base options for axios calls 94 | * 95 | * @type {any} 96 | * @memberof Configuration 97 | */ 98 | baseOptions?: BaseOptions; 99 | /** 100 | * credentials configuration 101 | * 102 | * @type {AuthCredentialsConfig} 103 | * @memberof Configuration 104 | */ 105 | credentials: AuthCredentialsConfig; 106 | /** 107 | * retry options in the case of too many requests 108 | * 109 | * @type {RetryParams} 110 | * @memberof Configuration 111 | */ 112 | retryParams?: RetryParams; 113 | /** 114 | * telemetry configuration 115 | * 116 | * @type {TelemetryConfiguration} 117 | * @memberof Configuration 118 | */ 119 | telemetry: TelemetryConfiguration; 120 | 121 | constructor(params: UserConfigurationParams = {} as unknown as UserConfigurationParams) { 122 | this.apiScheme = params.apiScheme || this.apiScheme; 123 | this.apiHost = params.apiHost!; 124 | this.apiUrl = params.apiUrl!; 125 | 126 | const credentialParams = params.credentials; 127 | 128 | if (credentialParams) { 129 | switch (credentialParams?.method) { 130 | case CredentialsMethod.ApiToken: 131 | this.credentials = { 132 | method: credentialParams.method, 133 | config: { 134 | token: credentialParams.config.token!, 135 | headerName: "Authorization", 136 | headerValuePrefix: "Bearer", 137 | } 138 | }; 139 | break; 140 | case CredentialsMethod.ClientCredentials: 141 | this.credentials = { 142 | method: CredentialsMethod.ClientCredentials, 143 | config: credentialParams.config 144 | }; 145 | break; 146 | case CredentialsMethod.None: 147 | default: 148 | this.credentials = { method: CredentialsMethod.None }; 149 | break; 150 | } 151 | } 152 | 153 | const baseOptions = params.baseOptions || {}; 154 | baseOptions.headers = baseOptions.headers || {}; 155 | 156 | if (typeof process === "object" && process.versions?.node && !baseOptions.headers["User-Agent"]) { 157 | baseOptions.headers["User-Agent"] = DEFAULT_USER_AGENT; 158 | } 159 | 160 | this.baseOptions = baseOptions; 161 | this.retryParams = params.retryParams; 162 | this.telemetry = new TelemetryConfiguration(params?.telemetry?.metrics); 163 | } 164 | 165 | /** 166 | * 167 | * @return {boolean} 168 | * @throws {FgaValidationError} 169 | */ 170 | public isValid(): boolean { 171 | if (!this.apiUrl && !this.apiHost) { 172 | assertParamExists("Configuration", "apiUrl", this.apiUrl); 173 | } 174 | 175 | if (!isWellFormedUriString(this.getBasePath())) { 176 | throw new FgaValidationError( 177 | this.apiUrl ? 178 | `Configuration.apiUrl (${this.apiUrl}) is not a valid URI (${this.getBasePath()})` : 179 | `Configuration.apiScheme (${this.apiScheme}) and Configuration.apiHost (${this.apiHost}) do not form a valid URI (${this.getBasePath()})` 180 | ); 181 | } 182 | 183 | if (this.retryParams?.maxRetry && this.retryParams.maxRetry > SdkConstants.RetryMaxAllowedNumber) { 184 | throw new FgaValidationError("Configuration.retryParams.maxRetry exceeds maximum allowed limit of 15"); 185 | } 186 | 187 | this.telemetry.ensureValid(); 188 | 189 | return true; 190 | } 191 | 192 | /** 193 | * Returns the API base path (apiScheme+apiHost) 194 | */ 195 | public getBasePath: () => string = () => { 196 | if (this.apiUrl) { 197 | return this.apiUrl; 198 | } else { 199 | return `${this.apiScheme}://${this.apiHost}`; 200 | } 201 | }; 202 | } 203 | -------------------------------------------------------------------------------- /docs/opentelemetry.md: -------------------------------------------------------------------------------- 1 | # OpenTelemetry 2 | 3 | This SDK produces [metrics](https://opentelemetry.io/docs/concepts/signals/metrics/) using [OpenTelemetry](https://opentelemetry.io/) to allow you to view data such as request timings. These metrics also include attributes for the model and store ID and the API called to allow you to build reporting. 4 | 5 | When an OpenTelemetry SDK instance is configured, the metrics will be exported and sent to the collector configured as part of your application's configuration. If you are not using OpenTelemetry, the metric functionality is a no-op and the events are never sent. 6 | 7 | In cases when metrics events are sent, they will not be viewable outside of infrastructure configured in your application, and are never available to the OpenFGA team or contributors. 8 | 9 | ## Metrics 10 | 11 | ### Supported Metrics 12 | 13 | | Metric Name | Type | Enabled by default | Description | 14 | |---------------------------------|-----------|--------------------|---------------------------------------------------------------------------------| 15 | | `fga-client.request.duration` | Histogram | Yes | The total request time for FGA requests | 16 | | `fga-client.query.duration` | Histogram | Yes | The amount of time the FGA server took to process the request | 17 | |` fga-client.credentials.request`| Counter | Yes | The total number of times a new token was requested when using ClientCredentials| 18 | 19 | ### Supported attributes 20 | 21 | | Attribute Name | Type | Enabled by default | Description | 22 | |--------------------------------|-----------|--------------------|-------------------------------------------------------------------------------------| 23 | | `fga-client.response.model_id` | `string` | Yes | The authorization model ID that the FGA server used | 24 | | `fga-client.request.method` | `string` | Yes | The FGA method/action that was performed | 25 | | `fga-client.request.store_id` | `string` | Yes | The store ID that was sent as part of the request | 26 | | `fga-client.request.model_id` | `string` | Yes | The authorization model ID that was sent as part of the request, if any | 27 | | `fga-client.request.client_id` | `string` | Yes | The client ID associated with the request, if any | 28 | | `fga-client.user` | `string` | No | The user that is associated with the action of the request for check and list users | 29 | | `http.status_code ` | `int` | Yes | The status code of the response | 30 | | `http.request.method` | `string` | No | The HTTP method for the request | 31 | | `http.host` | `string` | Yes | Host identifier of the origin the request was sent to | 32 | | `user_agent.original` | `string` | Yes | The user agent used in the query | 33 | | `url.full` | `string` | No | The full URL of the request | 34 | | `url.scheme` | `string` | No | HTTP Scheme of the request (http/https) | 35 | | `http.request.resend_count` | `int` | Yes | The number of retries attempted | 36 | | `http.client.request.duration` | `int` | No | Time taken by the FGA server to process and evaluate the request, rounded to the nearest milliseconds | 37 | | `http.server.request.duration` | `int` | No | The number of retries attempted | 38 | 39 | ## Default attributes 40 | 41 | Not all attributes are enabled by default. 42 | 43 | Some attributes, like `fga-client.user` have been disabled by default due to their high cardinality, which may result in very high costs when using some SaaS metric collectors. If you expect high cardinality for a specific attribute, you can disable it by updating the telemetry configuration accordingly. 44 | 45 | If your configuration does not specify a given metric, the default attributes for that metric will be used. 46 | 47 | 48 | ```javascript 49 | // define desired telemetry options 50 | const telemetryConfig = { 51 | metrics: { 52 | counterCredentialsRequest: { 53 | attributes: new Set([ 54 | TelemetryAttribute.UrlScheme, 55 | TelemetryAttribute.UserAgentOriginal, 56 | TelemetryAttribute.HttpRequestMethod, 57 | TelemetryAttribute.FgaClientRequestClientId, 58 | TelemetryAttribute.FgaClientRequestStoreId, 59 | TelemetryAttribute.FgaClientRequestModelId, 60 | TelemetryAttribute.HttpRequestResendCount, 61 | ]) 62 | }, 63 | histogramRequestDuration: { 64 | attributes: new Set([ 65 | TelemetryAttribute.HttpResponseStatusCode, 66 | TelemetryAttribute.UserAgentOriginal, 67 | TelemetryAttribute.FgaClientRequestMethod, 68 | TelemetryAttribute.FgaClientRequestClientId, 69 | TelemetryAttribute.FgaClientRequestStoreId, 70 | TelemetryAttribute.FgaClientRequestModelId, 71 | TelemetryAttribute.HttpRequestResendCount, 72 | ]) 73 | }, 74 | histogramQueryDuration: { 75 | attributes: new Set([ 76 | TelemetryAttribute.HttpResponseStatusCode, 77 | TelemetryAttribute.UserAgentOriginal, 78 | TelemetryAttribute.FgaClientRequestMethod, 79 | TelemetryAttribute.FgaClientRequestClientId, 80 | TelemetryAttribute.FgaClientRequestStoreId, 81 | TelemetryAttribute.FgaClientRequestModelId, 82 | TelemetryAttribute.HttpRequestResendCount, 83 | ]) 84 | } 85 | } 86 | }; 87 | 88 | const fgaClient = new OpenFgaClient({ 89 | apiUrl: process.env.FGA_API_URL, 90 | storeId: process.env.FGA_STORE_ID, 91 | authorizationModelId: process.env.FGA_MODEL_ID, 92 | credentials, 93 | telemetry: telemetryConfig, 94 | }); 95 | 96 | ``` 97 | 98 | ## Example 99 | 100 | There is an [example project](https://github.com/openfga/js-sdk/blob/main/example/opentelemetry) that provides some guidance on how to configure OpenTelemetry available in the examples directory. 101 | -------------------------------------------------------------------------------- /telemetry/configuration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | import { FgaValidationError } from "../errors"; 15 | import { TelemetryAttribute } from "./attributes"; 16 | import { TelemetryMetric, MetricRecorder } from "./metrics"; 17 | 18 | /** 19 | * Configuration for a telemetry metric. 20 | * 21 | * @interface TelemetryMetricConfig 22 | * @property {Set} attributes - A set of attributes associated with the telemetry metric. 23 | */ 24 | export interface TelemetryMetricConfig { 25 | attributes?: Set; 26 | } 27 | 28 | /** 29 | * Represents the overall configuration for telemetry, including various metrics. 30 | * 31 | * @interface TelemetryConfig 32 | * @property {Record} metrics - A record mapping telemetry metrics to their configurations. 33 | */ 34 | export interface TelemetryConfig { 35 | metrics?: Partial>; 36 | } 37 | 38 | /** 39 | * Manages the overall telemetry configuration, including default and valid attributes. 40 | * 41 | * @class TelemetryConfiguration 42 | * @implements {TelemetryConfig} 43 | */ 44 | export class TelemetryConfiguration implements TelemetryConfig { 45 | 46 | public readonly recorder: MetricRecorder = new MetricRecorder(); 47 | 48 | /** 49 | * Default attributes for telemetry metrics. 50 | * 51 | * @static 52 | * @readonly 53 | * @type {Set} 54 | */ 55 | public static readonly defaultAttributes: Set = new Set([ 56 | TelemetryAttribute.HttpHost, 57 | TelemetryAttribute.HttpResponseStatusCode, 58 | TelemetryAttribute.UserAgentOriginal, 59 | TelemetryAttribute.FgaClientRequestMethod, 60 | TelemetryAttribute.FgaClientRequestClientId, 61 | TelemetryAttribute.FgaClientRequestStoreId, 62 | TelemetryAttribute.FgaClientRequestModelId, 63 | TelemetryAttribute.HttpRequestResendCount, 64 | TelemetryAttribute.FgaClientResponseModelId, 65 | 66 | // These metrics are not included by default because they are usually less useful 67 | // TelemetryAttribute.UrlScheme, 68 | // TelemetryAttribute.HttpRequestMethod, 69 | // TelemetryAttribute.UrlFull, 70 | // TelemetryAttribute.HttpClientRequestDuration, 71 | // TelemetryAttribute.HttpServerRequestDuration, 72 | 73 | // This not included by default as it has a very high cardinality which could increase costs for users 74 | // TelemetryAttribute.FgaClientUser, 75 | // TelemetryAttribute.FgaClientRequestBatchCheckSize 76 | ]); 77 | 78 | /** 79 | * Valid attributes that can be used in telemetry metrics. 80 | * 81 | * @static 82 | * @readonly 83 | * @type {Set} 84 | */ 85 | public static readonly validAttributes: Set = new Set([ 86 | TelemetryAttribute.HttpHost, 87 | TelemetryAttribute.HttpResponseStatusCode, 88 | TelemetryAttribute.UserAgentOriginal, 89 | TelemetryAttribute.FgaClientRequestMethod, 90 | TelemetryAttribute.FgaClientRequestClientId, 91 | TelemetryAttribute.FgaClientRequestStoreId, 92 | TelemetryAttribute.FgaClientRequestModelId, 93 | TelemetryAttribute.HttpRequestResendCount, 94 | TelemetryAttribute.FgaClientResponseModelId, 95 | TelemetryAttribute.UrlScheme, 96 | TelemetryAttribute.HttpRequestMethod, 97 | TelemetryAttribute.UrlFull, 98 | TelemetryAttribute.HttpClientRequestDuration, 99 | TelemetryAttribute.HttpServerRequestDuration, 100 | TelemetryAttribute.FgaClientUser, 101 | TelemetryAttribute.FgaClientRequestBatchCheckSize, 102 | ]); 103 | 104 | /** 105 | * Creates an instance of TelemetryConfiguration. 106 | * 107 | * @param {Partial>} [metrics] - A record mapping telemetry metrics to their configurations. 108 | */ 109 | constructor( 110 | public metrics?: Partial>, 111 | ) { 112 | if (!metrics) { 113 | this.metrics = { 114 | [TelemetryMetric.CounterCredentialsRequest]: {attributes: TelemetryConfiguration.defaultAttributes}, 115 | [TelemetryMetric.HistogramRequestDuration]: {attributes: TelemetryConfiguration.defaultAttributes}, 116 | [TelemetryMetric.HistogramQueryDuration]: {attributes: TelemetryConfiguration.defaultAttributes}, 117 | }; 118 | } else { 119 | this.metrics = { 120 | [TelemetryMetric.CounterCredentialsRequest]: metrics[TelemetryMetric.CounterCredentialsRequest] || undefined, 121 | [TelemetryMetric.HistogramRequestDuration]: metrics[TelemetryMetric.HistogramRequestDuration] || undefined, 122 | [TelemetryMetric.HistogramQueryDuration]: metrics[TelemetryMetric.HistogramQueryDuration] || undefined, 123 | }; 124 | } 125 | } 126 | 127 | /** 128 | * Validates that the configured metrics use only valid attributes. 129 | * 130 | * @throws {FgaValidationError} Throws an error if any attribute in the metric configurations is invalid. 131 | */ 132 | public ensureValid(): void { 133 | const validAttrs = TelemetryConfiguration.validAttributes; 134 | 135 | const counterConfigAttrs = this.metrics?.counterCredentialsRequest?.attributes || new Set(); 136 | counterConfigAttrs.forEach(counterConfigAttr => { 137 | if (!validAttrs.has(counterConfigAttr)) { 138 | throw new FgaValidationError(`Configuration.telemetry.metrics.counterCredentialsRequest attribute '${counterConfigAttr}' is not a valid attribute`); 139 | } 140 | }); 141 | 142 | const histogramRequestDurationConfigAttrs = this.metrics?.histogramRequestDuration?.attributes || new Set(); 143 | histogramRequestDurationConfigAttrs.forEach(histogramRequestDurationAttr => { 144 | if (!validAttrs.has(histogramRequestDurationAttr)) { 145 | throw new FgaValidationError(`Configuration.telemetry.metrics.histogramRequestDuration attribute '${histogramRequestDurationAttr}' is not a valid attribute`); 146 | } 147 | }); 148 | 149 | const histogramQueryDurationConfigAttrs = this.metrics?.histogramQueryDuration?.attributes || new Set(); 150 | histogramQueryDurationConfigAttrs.forEach(histogramQueryDurationConfigAttr => { 151 | if (!validAttrs.has(histogramQueryDurationConfigAttr)) { 152 | throw new FgaValidationError(`Configuration.telemetry.metrics.histogramQueryDuration attribute '${histogramQueryDurationConfigAttr}' is not a valid attribute`); 153 | } 154 | }); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /example/example1/example1.mjs: -------------------------------------------------------------------------------- 1 | import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName, ClientWriteRequestOnDuplicateWrites } from "@openfga/sdk"; 2 | import { randomUUID } from "crypto"; 3 | 4 | async function main () { 5 | let credentials; 6 | if (process.env.FGA_CLIENT_ID) { 7 | credentials = { 8 | method: CredentialsMethod.ClientCredentials, 9 | config: { 10 | clientId: process.env.FGA_CLIENT_ID, 11 | clientSecret: process.env.FGA_CLIENT_SECRET, 12 | apiAudience: process.env.FGA_API_AUDIENCE, 13 | apiTokenIssuer: process.env.FGA_API_TOKEN_ISSUER 14 | } 15 | }; 16 | } 17 | 18 | const fgaClient = new OpenFgaClient({ 19 | apiUrl: process.env.FGA_API_URL || "http://localhost:8080", 20 | storeId: process.env.FGA_STORE_ID, // not needed when calling `createStore` or `listStores` 21 | authorizationModelId: process.env.FGA_MODEL_ID, // optional, recommended for production, 22 | credentials 23 | }); 24 | 25 | console.log("Listing stores"); 26 | const initialStores = await fgaClient.listStores(); 27 | console.log(`Stores count: ${initialStores.stores.length}`); 28 | 29 | console.log("Creating Test Store"); 30 | const createdStore = await fgaClient.createStore({name: "Test Store"}); 31 | const store = createdStore; 32 | console.log(`Test Store ID: ${store.id}`); 33 | 34 | // Set the store ID 35 | fgaClient.storeId = store.id; 36 | 37 | console.log("Listing Stores"); 38 | const { stores } = await fgaClient.listStores(); 39 | console.log(`Stores count: ${stores.length}`); 40 | 41 | console.log("Getting Current Store"); 42 | const currentStore = await fgaClient.getStore(); 43 | console.log(`Current Store Name ${currentStore.name}`); 44 | 45 | console.log("Reading Authorization Models"); 46 | const { authorization_models } = await fgaClient.readAuthorizationModels(); 47 | console.log(`Models Count: ${authorization_models.length}`); 48 | 49 | console.log("Reading Latest Authorization Model"); 50 | const { authorization_model } = await fgaClient.readLatestAuthorizationModel(); 51 | if (authorization_model) { 52 | console.log(`Latest Authorization Model ID: ${latestModel.authorization_model.id}`); 53 | } else { 54 | console.log("Latest Authorization Model not found"); 55 | } 56 | 57 | console.log("Writing an Authorization Model"); 58 | const model = await fgaClient.writeAuthorizationModel({ 59 | schema_version: "1.1", 60 | type_definitions: [ 61 | { 62 | type: "user" 63 | }, 64 | { 65 | type: "document", 66 | relations: { 67 | writer: { this: {} }, 68 | viewer: { 69 | union: { 70 | child: [ 71 | { this: {} }, 72 | { 73 | computedUserset: { 74 | relation: "writer" 75 | } 76 | } 77 | ] 78 | } 79 | } 80 | }, 81 | metadata: { 82 | relations: { 83 | writer: { 84 | directly_related_user_types: [ 85 | { type: "user" }, 86 | { type: "user", condition: "ViewCountLessThan200" } 87 | ] 88 | }, 89 | viewer: { 90 | directly_related_user_types: [ 91 | { type: "user" } 92 | ] 93 | } 94 | } 95 | } 96 | }, 97 | ], 98 | conditions: { 99 | "ViewCountLessThan200": { 100 | name: "ViewCountLessThan200", 101 | expression: "ViewCount < 200", 102 | parameters: { 103 | ViewCount: { 104 | type_name: TypeName.Int 105 | }, 106 | Type: { 107 | type_name: TypeName.String 108 | }, 109 | Name: { 110 | type_name: TypeName.String 111 | } 112 | } 113 | } 114 | } 115 | }); 116 | const authorizationModelId = model.authorization_model_id; 117 | console.log(`Authorization Model ID: ${authorizationModelId}`); 118 | 119 | console.log("Reading Authorization Models"); 120 | const models = await fgaClient.readAuthorizationModels(); 121 | console.log(`Models Count: ${models.authorization_models.length}`); 122 | 123 | console.log("Reading Latest Authorization Model"); 124 | const latestModel = await fgaClient.readLatestAuthorizationModel(); 125 | console.log(`Latest Authorization Model ID: ${latestModel.authorization_model.id}`); 126 | 127 | console.log("Writing Tuples"); 128 | await fgaClient.write({ 129 | writes: [ 130 | { 131 | user: "user:anne", 132 | relation: "writer", 133 | object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", 134 | condition: { 135 | name: "ViewCountLessThan200", 136 | context: { 137 | Name: "Roadmap", 138 | Type: "document" 139 | } 140 | } 141 | }, 142 | { 143 | user: "user:bob", 144 | relation: "writer", 145 | object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a" 146 | } 147 | ] 148 | }, { 149 | authorizationModelId, 150 | conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } 151 | }); 152 | console.log("Done Writing Tuples"); 153 | 154 | // Set the model ID 155 | fgaClient.authorizationModelId = authorizationModelId; 156 | 157 | console.log("Reading Tuples"); 158 | const { tuples } = await fgaClient.read(); 159 | console.log(`Read Tuples: ${JSON.stringify(tuples)}`); 160 | 161 | console.log("Reading Tuple Changes"); 162 | const { changes } = await fgaClient.readChanges(); 163 | console.log(`Tuple Changes: ${JSON.stringify(changes)}`); 164 | 165 | console.log("Checking for access"); 166 | try { 167 | const { allowed } = await fgaClient.check({ 168 | user: "user:anne", 169 | relation: "viewer", 170 | object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a" 171 | }); 172 | console.log(`Allowed: ${allowed}`); 173 | } catch (error) { 174 | if (error instanceof FgaApiValidationError) { 175 | console.log(`Failed due to ${error.apiErrorMessage}`); 176 | } else { 177 | throw error; 178 | } 179 | } 180 | 181 | console.log("Checking for access with context"); 182 | const { allowed } = await fgaClient.check({ 183 | user: "user:anne", 184 | relation: "viewer", 185 | object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", 186 | context: { 187 | ViewCount: 100 188 | } 189 | }); 190 | console.log(`Allowed: ${allowed}`); 191 | 192 | // execute a batch check 193 | const anneCorrelationId = randomUUID(); 194 | const { result } = await fgaClient.batchCheck({ 195 | checks: [ 196 | { 197 | // should have access 198 | user: "user:anne", 199 | relation: "viewer", 200 | object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", 201 | context: { 202 | ViewCount: 100 203 | }, 204 | correlationId: anneCorrelationId, 205 | }, 206 | { 207 | // should NOT have access 208 | user: "user:anne", 209 | relation: "viewer", 210 | object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a", 211 | } 212 | ] 213 | }); 214 | 215 | const anneAllowed = result.filter(r => r.correlationId === anneCorrelationId); 216 | console.log(`Anne is allowed access to ${anneAllowed.length} documents`); 217 | anneAllowed.forEach(item => { 218 | console.log(`Anne is allowed access to ${item.request.object}`); 219 | }); 220 | 221 | console.log("Writing Assertions"); 222 | await fgaClient.writeAssertions([ 223 | { 224 | user: "user:carl", 225 | relation: "writer", 226 | object: "document:budget", 227 | expectation: true 228 | }, 229 | { 230 | user: "user:carl", 231 | relation: "writer", 232 | object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", 233 | expectation: false 234 | } 235 | ]); 236 | console.log("Assertions Updated"); 237 | 238 | console.log("Reading Assertions"); 239 | const { assertions} = await fgaClient.readAssertions(); 240 | console.log(`Assertions: ${JSON.stringify(assertions)}`); 241 | 242 | console.log("Deleting Current Store"); 243 | await fgaClient.deleteStore(); 244 | console.log(`Deleted Store Count: ${store.id}`); 245 | } 246 | 247 | main() 248 | .catch(error => { 249 | console.error(`error: ${error}`); 250 | process.exitCode = 1; 251 | }); -------------------------------------------------------------------------------- /errors.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosHeaderValue, Method } from "axios"; 2 | import { 3 | ErrorCode, 4 | InternalErrorCode, 5 | NotFoundErrorCode, 6 | } from "./apiModel"; 7 | 8 | /** 9 | * 10 | * @export 11 | * @class FgaError 12 | * @extends {Error} 13 | */ 14 | export class FgaError extends Error { 15 | name = "FgaError"; 16 | 17 | constructor(err?: Error | string | unknown, msg?: string) { 18 | super( 19 | msg || typeof err === "string" 20 | ? (err as string) 21 | : `FGA Error${(err as Error)?.message ? `: ${(err as Error).message}` : ""}` 22 | ); 23 | if ((err as Error)?.stack) { 24 | this.stack = (err as Error).stack; 25 | } 26 | } 27 | } 28 | 29 | function getRequestMetadataFromPath(path?: string): { 30 | storeId: string; 31 | endpointCategory: string; 32 | } { 33 | // This function works because all paths start with /stores/{storeId}/{type} 34 | 35 | let splitPath: string[] = (path || "").split("/"); 36 | if (splitPath.length < 4) { 37 | splitPath = []; 38 | } 39 | const storeId = splitPath[2] || ""; 40 | const endpointCategory = splitPath[3] || ""; 41 | 42 | return { storeId, endpointCategory }; 43 | } 44 | 45 | const cFGARequestId = "fga-request-id"; 46 | 47 | function getResponseHeaders(err: AxiosError): any { 48 | return err.response 49 | ? Object.fromEntries( 50 | Object.entries(err.response.headers).map(([k, v]) => [ 51 | k.toLowerCase(), v, 52 | ]) 53 | ) 54 | : {}; 55 | } 56 | 57 | /** 58 | * 59 | * @export 60 | * @class FgaApiError 61 | * @extends { FgaError } 62 | */ 63 | export class FgaApiError extends FgaError { 64 | name = "FgaApiError"; 65 | public statusCode?: number; 66 | public statusText?: string; 67 | public method?: Method; 68 | public requestURL?: string; 69 | public storeId?: string; 70 | public endpointCategory?: string; 71 | public apiErrorMessage?: string; 72 | public requestData?: any; 73 | public responseData?: any; 74 | public responseHeader?: Record; 75 | public requestId?: string; 76 | 77 | constructor(err: AxiosError, msg?: string) { 78 | super(msg ? msg : err); 79 | this.statusCode = err.response?.status; 80 | this.statusText = err.response?.statusText; 81 | this.requestData = err.config?.data; 82 | this.requestURL = err.config?.url; 83 | this.method = err.config?.method as Method; 84 | const { storeId, endpointCategory } = getRequestMetadataFromPath( 85 | err.request?.path 86 | ); 87 | this.storeId = storeId; 88 | this.endpointCategory = endpointCategory; 89 | this.apiErrorMessage = (err.response?.data as any)?.message; 90 | this.responseData = err.response?.data; 91 | this.responseHeader = err.response?.headers; 92 | const errResponseHeaders = getResponseHeaders(err); 93 | this.requestId = errResponseHeaders[cFGARequestId]; 94 | 95 | if ((err as Error)?.stack) { 96 | this.stack = (err as Error).stack; 97 | } 98 | } 99 | } 100 | 101 | /** 102 | * 103 | * @export 104 | * @class FgaApiValidationError 105 | * @extends { FgaApiError } 106 | */ 107 | export class FgaApiValidationError extends FgaApiError { 108 | name = "FgaApiValidationError"; 109 | public apiErrorCode: ErrorCode; 110 | constructor(err: AxiosError, msg?: string) { 111 | // If there is a better error message, use it instead of the default error 112 | super(err); 113 | this.apiErrorCode = (err.response?.data as any)?.code; 114 | const { endpointCategory } = getRequestMetadataFromPath(err.request?.path); 115 | this.message = msg 116 | ? msg 117 | : (err.response?.data as any)?.message 118 | ? `FGA API Validation Error: ${err.config?.method} ${endpointCategory} : Error ${(err.response?.data as any)?.message}` 119 | : (err as Error).message; 120 | } 121 | } 122 | 123 | /** 124 | * 125 | * @export 126 | * @class FgaApiNotFoundError 127 | * @extends { FgaApiError } 128 | */ 129 | export class FgaApiNotFoundError extends FgaApiError { 130 | name = "FgaApiNotFoundError"; 131 | public apiErrorCode: NotFoundErrorCode; 132 | constructor(err: AxiosError, msg?: string) { 133 | // If there is a better error message, use it instead of the default error 134 | super(err); 135 | this.apiErrorCode = (err.response?.data as any)?.code; 136 | this.message = msg 137 | ? msg 138 | : (err.response?.data as any)?.message 139 | ? `FGA API NotFound Error: ${err.config?.method} : Error ${(err.response?.data as any)?.message}` 140 | : (err as Error).message; 141 | } 142 | } 143 | 144 | const cXRateLimit = "x-ratelimit-limit"; 145 | const cXRateLimitReset = "x-ratelimit-reset"; 146 | /** 147 | * 148 | * @export 149 | * @class FgaApiRateLimitExceededError 150 | * @extends { FgaApiError } 151 | */ 152 | export class FgaApiRateLimitExceededError extends FgaApiError { 153 | name = "FgaApiRateLimitExceededError"; 154 | public apiErrorCode?: string; 155 | 156 | constructor(err: AxiosError, msg?: string) { 157 | super(err); 158 | this.apiErrorCode = (err.response?.data as any)?.code; 159 | 160 | const { endpointCategory } = getRequestMetadataFromPath(err.request?.path); 161 | const errResponseHeaders = getResponseHeaders(err); 162 | this.message = msg 163 | ? msg 164 | : `FGA API Rate Limit Error for ${this.method} ${endpointCategory}`; 165 | } 166 | } 167 | 168 | /** 169 | * 170 | * @export 171 | * @class FgaApiInternalError 172 | * @extends { FgaApiError } 173 | */ 174 | export class FgaApiInternalError extends FgaApiError { 175 | name = "FgaApiInternalError"; 176 | public apiErrorCode: InternalErrorCode; 177 | 178 | constructor(err: AxiosError, msg?: string) { 179 | // If there is a better error message, use it instead of the default error 180 | super(err); 181 | const { endpointCategory } = getRequestMetadataFromPath(err.request?.path); 182 | this.apiErrorCode = (err.response?.data as any)?.code; 183 | 184 | this.message = msg 185 | ? msg 186 | : (err.response?.data as any)?.message 187 | ? `FGA API Internal Error: ${err.config?.method} ${endpointCategory} : Error ${(err.response?.data as any)?.message}` 188 | : (err as Error).message; 189 | } 190 | } 191 | 192 | /** 193 | * 194 | * @export 195 | * @class FgaApiAuthenticationError 196 | * @extends { FgaApiError } 197 | */ 198 | export class FgaApiAuthenticationError extends FgaError { 199 | name = "FgaApiAuthenticationError"; 200 | public statusCode?: number; 201 | public statusText?: string; 202 | public method?: Method; 203 | public requestURL?: string; 204 | public clientId?: string; 205 | public audience?: string; 206 | public grantType?: string; 207 | public responseData?: any; 208 | public responseHeader?: any; 209 | public requestId?: string; 210 | public apiErrorCode?: string; 211 | 212 | constructor(err: AxiosError) { 213 | super(`FGA Authentication Error.${err.response?.statusText ? ` ${err.response.statusText}` : ""}`); 214 | this.statusCode = err.response?.status; 215 | this.statusText = err.response?.statusText; 216 | this.requestURL = err.config?.url; 217 | this.method = err.config?.method as Method; 218 | this.responseData = err.response?.data; 219 | this.responseHeader = err.response?.headers; 220 | this.apiErrorCode = (err.response?.data as any)?.code; 221 | 222 | const errResponseHeaders = getResponseHeaders(err); 223 | this.requestId = errResponseHeaders[cFGARequestId]; 224 | 225 | let data: any; 226 | try { 227 | data = JSON.parse(err.config?.data || "{}"); 228 | } catch (err) { 229 | /* do nothing */ 230 | } 231 | this.clientId = data?.client_id; 232 | this.audience = data?.audience; 233 | this.grantType = data?.grant_type; 234 | if ((err as Error)?.stack) { 235 | this.stack = (err as Error).stack; 236 | } 237 | } 238 | } 239 | 240 | /** 241 | * 242 | * @export 243 | * @class FgaValidationError 244 | * @extends { FgaError } 245 | */ 246 | export class FgaValidationError extends FgaError { 247 | name = "FgaValidationError"; 248 | constructor(public field: string, msg?: string) { 249 | super(msg); 250 | } 251 | } 252 | 253 | /** 254 | * 255 | * @export 256 | * @class FgaRequiredParamError 257 | * @extends { FgaValidationError } 258 | */ 259 | export class FgaRequiredParamError extends FgaValidationError { 260 | name = "FgaRequiredParamError"; 261 | constructor(public functionName: string, field: string, msg?: string) { 262 | super(field, msg); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /tests/helpers/nocks.ts: -------------------------------------------------------------------------------- 1 | import type * as Nock from "nock"; 2 | 3 | import { 4 | AuthorizationModel, 5 | BatchCheckRequest, 6 | BatchCheckResponse, 7 | CheckRequest, 8 | CheckResponse, 9 | ConsistencyPreference, 10 | CreateStoreResponse, 11 | ExpandRequest, 12 | ExpandResponse, 13 | GetStoreResponse, 14 | ListObjectsResponse, 15 | ListStoresResponse, 16 | ListUsersRequest, 17 | ListUsersResponse, 18 | ReadAssertionsResponse, 19 | ReadAuthorizationModelResponse, 20 | ReadAuthorizationModelsResponse, 21 | ReadChangesResponse, 22 | ReadRequest, 23 | ReadResponse, 24 | TupleKey, 25 | TupleOperation, 26 | WriteAuthorizationModelRequest, 27 | } from "../../index"; 28 | import { defaultConfiguration } from "./default-config"; 29 | 30 | export const getNocks = ((nock: typeof Nock) => ({ 31 | tokenExchange: ( 32 | apiTokenIssuer: string, 33 | accessToken = "test-token", 34 | expiresIn = 300, 35 | statusCode = 200, 36 | headers = {}, 37 | ) => { 38 | return nock(`https://${apiTokenIssuer}`, { reqheaders: { "Content-Type": "application/x-www-form-urlencoded"} }) 39 | .post("/oauth/token") 40 | .reply(statusCode, { 41 | access_token: accessToken, 42 | expires_in: expiresIn, 43 | }, headers); 44 | }, 45 | listStores: ( 46 | basePath = defaultConfiguration.getBasePath(), 47 | response: ListStoresResponse = { 48 | continuation_token: "...", 49 | stores: [{ 50 | id: "some-id", 51 | name: "some-name", 52 | created_at: "2023-11-02T15:27:47.951Z", 53 | updated_at: "2023-11-02T15:27:47.951Z", 54 | deleted_at: "2023-11-02T15:27:47.951Z", 55 | }] 56 | }, 57 | responseCode = 200, 58 | queryParams?: { page_size?: number; continuation_token?: string; name?: string }, 59 | ) => { 60 | const mock = nock(basePath).get("/stores"); 61 | if (queryParams) { 62 | mock.query(queryParams); 63 | } 64 | return mock.reply(responseCode, response); 65 | }, 66 | createStore: ( 67 | basePath = defaultConfiguration.getBasePath(), 68 | response: CreateStoreResponse = { 69 | id: "some-id", 70 | name: "some-name", 71 | created_at: "2023-11-02T15:27:47.951Z", 72 | updated_at: "2023-11-02T15:27:47.951Z", 73 | }, 74 | responseCode = 200, 75 | ) => { 76 | return nock(basePath) 77 | .post("/stores") 78 | .reply(responseCode, response); 79 | }, 80 | getStore: ( 81 | storeId: string, 82 | basePath = defaultConfiguration.getBasePath(), 83 | response: GetStoreResponse = { 84 | id: "some-id", 85 | name: "some-name", 86 | created_at: "2023-11-02T15:27:47.951Z", 87 | updated_at: "2023-11-02T15:27:47.951Z", 88 | }, 89 | responseCode = 200, 90 | ) => { 91 | return nock(basePath) 92 | .get(`/stores/${storeId}`) 93 | .reply(responseCode, response); 94 | }, 95 | deleteStore: ( 96 | storeId: string, 97 | basePath = defaultConfiguration.getBasePath(), 98 | responseCode = 204, 99 | ) => { 100 | return nock(basePath) 101 | .delete(`/stores/${storeId}`) 102 | .reply(responseCode); 103 | }, 104 | readAuthorizationModels: ( 105 | storeId: string, 106 | basePath = defaultConfiguration.getBasePath(), 107 | authorizationModels: AuthorizationModel[] = [{ id: "some-id", schema_version: "1.1", type_definitions: [] }], 108 | statusCode = 200, 109 | ) => { 110 | return nock(basePath) 111 | .get(`/stores/${storeId}/authorization-models`) 112 | .reply(statusCode, { 113 | authorization_models: authorizationModels, 114 | } as ReadAuthorizationModelsResponse); 115 | }, 116 | writeAuthorizationModel: ( 117 | storeId: string, 118 | configurations: WriteAuthorizationModelRequest, 119 | basePath = defaultConfiguration.getBasePath(), 120 | ) => { 121 | return nock(basePath) 122 | .post(`/stores/${storeId}/authorization-models`) 123 | .reply(200, { 124 | id: "some-new-id", 125 | } as ReadAuthorizationModelResponse); 126 | }, 127 | readSingleAuthzModel: ( 128 | storeId: string, 129 | configId: string, 130 | basePath = defaultConfiguration.getBasePath(), 131 | authorizationModel: AuthorizationModel = { id: "some-id", schema_version: "1.1", type_definitions: [] }, 132 | ) => { 133 | return nock(basePath) 134 | .get(`/stores/${storeId}/authorization-models/${configId}`) 135 | .reply(200, { 136 | authorization_model: authorizationModel 137 | }); 138 | }, 139 | readChanges: (storeId: string, type: string, pageSize: number, contToken: string, startTime: string, basePath = defaultConfiguration.getBasePath()) => { 140 | return nock(basePath) 141 | .get(`/stores/${storeId}/changes`) 142 | .query({ 143 | page_size: pageSize, 144 | continuation_token: contToken, 145 | ...(type ? { type } : { }), 146 | ...(startTime ? {start_time: startTime } :{}) 147 | }) 148 | .reply(200, { 149 | changes: [{ 150 | tuple_key: { 151 | user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", 152 | relation: "viewer", 153 | object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a" 154 | }, 155 | operation: TupleOperation.Write, 156 | timestamp: "2000-01-01T00:00:00Z" 157 | }], 158 | "continuation_token": "eyJwayI6IkxBVEVTVF9OU0NPTkZJR19hdXRoMHN0b3JlIiwic2siOiIxem1qbXF3MWZLZExTcUoyN01MdTdqTjh0cWgifQ==" 159 | } as ReadChangesResponse); 160 | }, 161 | read: ( 162 | storeId: string, 163 | tuple: TupleKey, 164 | basePath = defaultConfiguration.getBasePath(), 165 | consistency: ConsistencyPreference|undefined = undefined, 166 | ) => { 167 | return nock(basePath) 168 | .post(`/stores/${storeId}/read`, (body: ReadRequest) => 169 | body.consistency === consistency 170 | ) 171 | .reply(200, { tuples: [], continuation_token: "" } as ReadResponse); 172 | }, 173 | write: ( 174 | storeId: string, 175 | basePath = defaultConfiguration.getBasePath(), 176 | responseBody: object | { code: string, message: string } = {}, 177 | statusCode = 204, 178 | ) => { 179 | return nock(basePath) 180 | .post(`/stores/${storeId}/write`) 181 | .reply(statusCode, responseBody); 182 | }, 183 | delete: ( 184 | storeId: string, 185 | tuple: TupleKey, 186 | basePath = defaultConfiguration.getBasePath(), 187 | ) => { 188 | return nock(basePath) 189 | .post(`/stores/${storeId}/write`) 190 | .reply(200, {} as Promise); 191 | }, 192 | check: ( 193 | storeId: string, 194 | tuple: TupleKey, 195 | basePath = defaultConfiguration.getBasePath(), 196 | response: { allowed: boolean } | { code: string, message: string } = { allowed: true }, 197 | statusCode = 200, 198 | consistency: ConsistencyPreference|undefined = undefined, 199 | ) => { 200 | return nock(basePath) 201 | .post(`/stores/${storeId}/check`, (body: CheckRequest) => 202 | body.tuple_key.user === tuple.user && 203 | body.tuple_key.relation === tuple.relation && 204 | body.tuple_key.object === tuple.object && 205 | body.consistency === consistency 206 | ) 207 | .reply(statusCode, response as CheckResponse); 208 | }, 209 | singleBatchCheck: ( 210 | storeId: string, 211 | responseBody: BatchCheckResponse, 212 | basePath = defaultConfiguration.getBasePath(), 213 | consistency: ConsistencyPreference|undefined | undefined, 214 | authorizationModelId = "auth-model-id", 215 | ) => { 216 | return nock(basePath) 217 | .post(`/stores/${storeId}/batch-check`, (body: BatchCheckRequest) => 218 | body.consistency === consistency && 219 | body.authorization_model_id === authorizationModelId 220 | ) 221 | .reply(200, responseBody); 222 | }, 223 | expand: ( 224 | storeId: string, 225 | tuple: TupleKey, 226 | basePath = defaultConfiguration.getBasePath(), 227 | consistency: ConsistencyPreference|undefined = undefined, 228 | ) => { 229 | return nock(basePath) 230 | .post(`/stores/${storeId}/expand`, (body: ExpandRequest) => 231 | body.consistency === consistency 232 | ) 233 | .reply(200, { tree: {} } as ExpandResponse); 234 | }, 235 | listObjects: ( 236 | storeId: string, 237 | responseBody: ListObjectsResponse, 238 | basePath = defaultConfiguration.getBasePath(), 239 | consistency: ConsistencyPreference|undefined = undefined, 240 | ) => { 241 | return nock(basePath) 242 | .post(`/stores/${storeId}/list-objects`, (body: ListUsersRequest) => 243 | body.consistency === consistency 244 | ) 245 | .reply(200, responseBody); 246 | }, 247 | listUsers: ( 248 | storeId: string, 249 | responseBody: ListUsersResponse, 250 | basePath = defaultConfiguration.getBasePath(), 251 | consistency: ConsistencyPreference|undefined = undefined 252 | ) => { 253 | return nock(basePath) 254 | .post(`/stores/${storeId}/list-users`, (body: ListUsersRequest) => 255 | body.consistency === consistency 256 | ) 257 | .reply(200, responseBody); 258 | }, 259 | readAssertions: (storeId: string, modelId: string, assertions: ReadAssertionsResponse["assertions"] = [], basePath = defaultConfiguration.getBasePath()) => { 260 | return nock(basePath) 261 | .get(`/stores/${storeId}/assertions/${modelId}`) 262 | .reply(200, { 263 | authorization_model_id: modelId, 264 | assertions, 265 | }); 266 | }, 267 | writeAssertions: (storeId: string, modelId: string, basePath = defaultConfiguration.getBasePath(), responseStatus = 204) => { 268 | return nock(basePath) 269 | .put(`/stores/${storeId}/assertions/${modelId}`) 270 | .reply(responseStatus); 271 | }, 272 | })); 273 | -------------------------------------------------------------------------------- /credentials/credentials.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JavaScript and Node.js SDK for OpenFGA 3 | * 4 | * API version: 1.x 5 | * Website: https://openfga.dev 6 | * Documentation: https://openfga.dev/docs 7 | * Support: https://openfga.dev/community 8 | * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) 9 | * 10 | * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. 11 | */ 12 | 13 | 14 | import globalAxios, { AxiosInstance } from "axios"; 15 | import * as jose from "jose"; 16 | 17 | import { assertParamExists, isWellFormedUriString } from "../validation"; 18 | import { FgaApiAuthenticationError, FgaApiError, FgaValidationError } from "../errors"; 19 | import { attemptHttpRequest } from "../common"; 20 | import { AuthCredentialsConfig, PrivateKeyJWTConfig, ClientCredentialsConfig, ClientSecretConfig, CredentialsMethod } from "./types"; 21 | import { TelemetryAttributes } from "../telemetry/attributes"; 22 | import { TelemetryCounters } from "../telemetry/counters"; 23 | 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 | } 39 | 40 | export class Credentials { 41 | private accessToken?: string; 42 | private accessTokenExpiryDate?: Date; 43 | 44 | public static init(configuration: { credentials: AuthCredentialsConfig, telemetry: TelemetryConfiguration, baseOptions?: any }, axios: AxiosInstance = globalAxios): Credentials { 45 | return new Credentials(configuration.credentials, axios, configuration.telemetry, configuration.baseOptions); 46 | } 47 | 48 | public constructor(private authConfig: AuthCredentialsConfig, private axios: AxiosInstance = globalAxios, private telemetryConfig: TelemetryConfiguration, private baseOptions?: any) { 49 | this.initConfig(); 50 | this.isValid(); 51 | } 52 | 53 | /** 54 | * Sets the default config values 55 | * @private 56 | */ 57 | private initConfig() { 58 | switch (this.authConfig?.method) { 59 | case CredentialsMethod.ApiToken: 60 | 61 | if (this.authConfig.config) { 62 | if (!this.authConfig.config.headerName) { 63 | this.authConfig.config.headerName = "Authorization"; 64 | } 65 | if (!this.authConfig.config.headerValuePrefix) { 66 | this.authConfig.config.headerValuePrefix = "Bearer"; 67 | } 68 | } 69 | break; 70 | case CredentialsMethod.None: 71 | default: 72 | break; 73 | } 74 | } 75 | 76 | /** 77 | * 78 | * @throws {FgaValidationError} 79 | */ 80 | public isValid(): void { 81 | const { authConfig } = this; 82 | switch (authConfig?.method) { 83 | case CredentialsMethod.None: 84 | break; 85 | case CredentialsMethod.ApiToken: 86 | assertParamExists("Credentials", "config.token", authConfig.config?.token); 87 | assertParamExists("Credentials", "config.headerName", authConfig.config?.headerName); 88 | assertParamExists("Credentials", "config.headerName", authConfig.config?.headerName); 89 | break; 90 | case CredentialsMethod.ClientCredentials: 91 | assertParamExists("Credentials", "config.clientId", authConfig.config?.clientId); 92 | assertParamExists("Credentials", "config.apiTokenIssuer", authConfig.config?.apiTokenIssuer); 93 | assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience); 94 | assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey); 95 | 96 | if (!isWellFormedUriString(`https://${authConfig.config?.apiTokenIssuer}`)) { 97 | throw new FgaValidationError( 98 | `Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`); 99 | } 100 | break; 101 | } 102 | } 103 | 104 | /** 105 | * Get access token, request a new one if not cached or expired 106 | * @return string 107 | */ 108 | public async getAccessTokenHeader(): Promise<{ name: string; value: string } | undefined> { 109 | const accessTokenValue = await this.getAccessTokenValue(); 110 | switch (this.authConfig?.method) { 111 | case CredentialsMethod.None: 112 | return; 113 | case CredentialsMethod.ApiToken: 114 | return { 115 | name: this.authConfig.config.headerName, 116 | value: `${this.authConfig.config.headerValuePrefix ? `${this.authConfig.config.headerValuePrefix} ` : ""}${accessTokenValue}` 117 | }; 118 | case CredentialsMethod.ClientCredentials: 119 | return { 120 | name: "Authorization", 121 | value: `Bearer ${accessTokenValue}` 122 | }; 123 | } 124 | } 125 | 126 | private async getAccessTokenValue(): Promise { 127 | switch (this.authConfig?.method) { 128 | case CredentialsMethod.None: 129 | return; 130 | case CredentialsMethod.ApiToken: 131 | return this.authConfig.config.token; 132 | case CredentialsMethod.ClientCredentials: 133 | if (this.accessToken && (!this.accessTokenExpiryDate || this.accessTokenExpiryDate > new Date())) { 134 | return this.accessToken; 135 | } 136 | 137 | return this.refreshAccessToken(); 138 | } 139 | } 140 | 141 | /** 142 | * Request new access token 143 | * @return string 144 | */ 145 | private async refreshAccessToken() { 146 | const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config; 147 | const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`; 148 | const credentialsPayload = await this.buildClientAuthenticationPayload(); 149 | 150 | try { 151 | const wrappedResponse = await attemptHttpRequest({ 155 | url, 156 | method: "POST", 157 | data: credentialsPayload, 158 | headers: { 159 | "Content-Type": "application/x-www-form-urlencoded" 160 | } 161 | }, { 162 | maxRetry: 3, 163 | minWaitInMs: 100, 164 | }, this.axios); 165 | 166 | const response = wrappedResponse?.response; 167 | if (response) { 168 | this.accessToken = response.data.access_token; 169 | this.accessTokenExpiryDate = new Date(Date.now() + response.data.expires_in * 1000); 170 | } 171 | 172 | if (this.telemetryConfig?.metrics?.counterCredentialsRequest?.attributes) { 173 | 174 | let attributes = {}; 175 | 176 | attributes = TelemetryAttributes.fromRequest({ 177 | userAgent: this.baseOptions?.headers["User-Agent"], 178 | fgaMethod: "TokenExchange", 179 | url, 180 | resendCount: wrappedResponse?.retries, 181 | httpMethod: "POST", 182 | credentials: clientCredentials, 183 | start: performance.now(), 184 | attributes, 185 | }); 186 | 187 | attributes = TelemetryAttributes.fromResponse({ 188 | response, 189 | attributes, 190 | }); 191 | 192 | attributes = TelemetryAttributes.prepare(attributes, this.telemetryConfig.metrics?.counterCredentialsRequest?.attributes); 193 | this.telemetryConfig.recorder.counter(TelemetryCounters.credentialsRequest, 1, attributes); 194 | } 195 | 196 | return this.accessToken; 197 | } catch (err: unknown) { 198 | if (err instanceof FgaApiError) { 199 | (err as any).constructor = FgaApiAuthenticationError; 200 | (err as any).name = "FgaApiAuthenticationError"; 201 | (err as any).clientId = clientCredentials.clientId; 202 | (err as any).audience = clientCredentials.apiAudience; 203 | (err as any).grantType = "client_credentials"; 204 | } 205 | 206 | throw err; 207 | } 208 | } 209 | 210 | private async buildClientAuthenticationPayload(): Promise { 211 | if (this.authConfig?.method !== CredentialsMethod.ClientCredentials) { 212 | throw new FgaValidationError("Credentials method is not set to ClientCredentials"); 213 | } 214 | 215 | const config = this.authConfig.config; 216 | if ((config as PrivateKeyJWTConfig).clientAssertionSigningKey) { 217 | const alg = (config as PrivateKeyJWTConfig).clientAssertionSigningAlgorithm || "RS256"; 218 | const privateKey = await jose.importPKCS8((config as PrivateKeyJWTConfig).clientAssertionSigningKey, alg); 219 | const assertion = await new jose.SignJWT({}) 220 | .setProtectedHeader({ alg }) 221 | .setIssuedAt() 222 | .setSubject(config.clientId) 223 | .setJti(randomUUID()) 224 | .setIssuer(config.clientId) 225 | .setAudience(`https://${config.apiTokenIssuer}/`) 226 | .setExpirationTime("2m") 227 | .sign(privateKey); 228 | return { 229 | ...config.customClaims, 230 | client_id: (config as PrivateKeyJWTConfig).clientId, 231 | client_assertion: assertion, 232 | audience: config.apiAudience, 233 | client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 234 | grant_type: "client_credentials", 235 | } as ClientAssertionRequest; 236 | } else if ((config as ClientSecretConfig).clientSecret) { 237 | return { 238 | ...config.customClaims, 239 | client_id: (config as ClientSecretConfig).clientId, 240 | client_secret: (config as ClientSecretConfig).clientSecret, 241 | audience: (config as ClientSecretConfig).apiAudience, 242 | grant_type: "client_credentials", 243 | }; 244 | } 245 | 246 | throw new FgaValidationError("Credentials method is set to ClientCredentials, but no clientSecret or clientAssertionSigningKey is provided"); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | 180 | -------------------------------------------------------------------------------- /common.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; 2 | 3 | import { Configuration } from "./configuration"; 4 | import SdkConstants from "./constants"; 5 | import type { Credentials } from "./credentials"; 6 | import { 7 | FgaApiError, 8 | FgaApiInternalError, 9 | FgaApiAuthenticationError, 10 | FgaApiNotFoundError, 11 | FgaApiRateLimitExceededError, 12 | FgaApiValidationError, 13 | FgaError 14 | } from "./errors"; 15 | import { setNotEnumerableProperty } from "./utils"; 16 | import { TelemetryAttribute, TelemetryAttributes } from "./telemetry/attributes"; 17 | import { TelemetryHistograms } from "./telemetry/histograms"; 18 | 19 | /** 20 | * 21 | * @export 22 | */ 23 | export const DUMMY_BASE_URL = `https://${SdkConstants.SampleBaseDomain}`; 24 | 25 | /** 26 | * 27 | * @export 28 | * @interface RequestArgs 29 | */ 30 | export interface RequestArgs { 31 | url: string; 32 | options: any; 33 | } 34 | 35 | 36 | /** 37 | * 38 | * @export 39 | */ 40 | export const setBearerAuthToObject = async function (object: any, credentials: Credentials) { 41 | const accessTokenHeader = await credentials.getAccessTokenHeader(); 42 | if (accessTokenHeader && !object[accessTokenHeader.name]) { 43 | object[accessTokenHeader.name] = accessTokenHeader.value; 44 | } 45 | }; 46 | 47 | /** 48 | * 49 | * @export 50 | */ 51 | export const setSearchParams = function (url: URL, ...objects: any[]) { 52 | const searchParams = new URLSearchParams(url.search); 53 | for (const object of objects) { 54 | for (const key in object) { 55 | if (Array.isArray(object[key])) { 56 | searchParams.delete(key); 57 | for (const item of object[key]) { 58 | searchParams.append(key, item); 59 | } 60 | } else { 61 | searchParams.set(key, object[key]); 62 | } 63 | } 64 | } 65 | url.search = searchParams.toString(); 66 | }; 67 | 68 | /** 69 | * Check if the given MIME is a JSON MIME. 70 | * JSON MIME examples: 71 | * application/json 72 | * application/json; charset=UTF8 73 | * APPLICATION/JSON 74 | * application/vnd.company+json 75 | * @param mime - MIME (Multipurpose Internet Mail Extensions) 76 | * @return True if the given MIME is JSON, false otherwise. 77 | */ 78 | const isJsonMime = (mime: string): boolean => { 79 | // eslint-disable-next-line no-control-regex 80 | const jsonMime = new RegExp("^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$", "i"); 81 | return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === "application/json-patch+json"); 82 | }; 83 | 84 | /** 85 | * 86 | * @export 87 | */ 88 | export const serializeDataIfNeeded = function (value: any, requestOptions: any) { 89 | const nonString = typeof value !== "string"; 90 | const needsSerialization = nonString 91 | ? isJsonMime(requestOptions.headers["Content-Type"]) 92 | : nonString; 93 | return needsSerialization 94 | ? JSON.stringify(value !== undefined ? value : {}) 95 | : (value || ""); 96 | }; 97 | 98 | /** 99 | * 100 | * @export 101 | */ 102 | export const toPathString = function (url: URL) { 103 | return url.pathname + url.search + url.hash; 104 | }; 105 | 106 | type ObjectOrVoid = object | void; 107 | 108 | interface StringIndexable { 109 | [key: string]: any; 110 | } 111 | 112 | export type CallResult = T & { 113 | $response: AxiosResponse 114 | }; 115 | 116 | export type PromiseResult = Promise>; 117 | 118 | /** 119 | * Returns true if this error is returned from axios 120 | * source: https://github.com/axios/axios/blob/21a5ad34c4a5956d81d338059ac0dd34a19ed094/lib/helpers/isAxiosError.js#L12 121 | * @param err 122 | */ 123 | function isAxiosError(err: any): boolean { 124 | return err && typeof err === "object" && err.isAxiosError === true; 125 | } 126 | function calculateExponentialBackoffWithJitter(retryAttempt: number, minWaitInMs: number): number { 127 | const minDelayMs = Math.ceil(2 ** retryAttempt * minWaitInMs); 128 | const maxDelayMs = Math.ceil(2 ** (retryAttempt + 1) * minWaitInMs); 129 | const randomDelayMs = Math.floor(Math.random() * (maxDelayMs - minDelayMs) + minDelayMs); 130 | return Math.min(randomDelayMs, SdkConstants.MaxBackoffTimeInSec * 1000); 131 | } 132 | 133 | /** 134 | * Validates if a retry delay is within acceptable bounds 135 | * @param delayMs - Delay in milliseconds 136 | * @returns True if delay is between {@link SdkConstants.DefaultMinWaitInMs}ms and {@link SdkConstants.RetryHeaderMaxAllowableDurationInSec}s 137 | */ 138 | function isValidRetryDelay(delayMs: number): boolean { 139 | return delayMs >= SdkConstants.DefaultMinWaitInMs && delayMs <= SdkConstants.RetryHeaderMaxAllowableDurationInSec * 1000; 140 | } 141 | 142 | /** 143 | * Parses the Retry-After header and returns delay in milliseconds 144 | * @param headers - HTTP response headers 145 | * @returns Delay in milliseconds if valid, undefined otherwise 146 | */ 147 | function parseRetryAfterHeader(headers: Record): number | undefined { 148 | // Find the retry-after header regardless of case 149 | const retryAfterHeaderNameLower = SdkConstants.RetryAfterHeaderName.toLowerCase(); 150 | const retryAfterKey = Object.keys(headers).find(key => 151 | key.toLowerCase() === retryAfterHeaderNameLower 152 | ); 153 | 154 | const retryAfterHeader = retryAfterKey ? headers[retryAfterKey] : undefined; 155 | 156 | if (!retryAfterHeader) { 157 | return undefined; 158 | } 159 | 160 | const retryAfterHeaderValue = Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader; 161 | 162 | if (!retryAfterHeaderValue) { 163 | return undefined; 164 | } 165 | 166 | // Try to parse as integer (seconds) 167 | const retryAfterSeconds = parseInt(retryAfterHeaderValue, 10); 168 | if (!isNaN(retryAfterSeconds)) { 169 | const retryAfterMs = retryAfterSeconds * 1000; 170 | if (isValidRetryDelay(retryAfterMs)) { 171 | return retryAfterMs; 172 | } 173 | return undefined; 174 | } 175 | 176 | // Try to parse as HTTP date 177 | try { 178 | const retryAfterDate = new Date(retryAfterHeaderValue); 179 | const currentDate = new Date(); 180 | const retryDelayMs = retryAfterDate.getTime() - currentDate.getTime(); 181 | 182 | if (isValidRetryDelay(retryDelayMs)) { 183 | return retryDelayMs; 184 | } 185 | } catch (e) { 186 | // Invalid date format 187 | } 188 | 189 | return undefined; 190 | } 191 | 192 | interface WrappedAxiosResponse { 193 | response?: AxiosResponse; 194 | retries: number; 195 | } 196 | 197 | function checkIfRetryableError( 198 | err: any, 199 | iterationCount: number, 200 | maxRetry: number 201 | ): { retryable: boolean; error?: Error } { 202 | if (!isAxiosError(err)) { 203 | return { retryable: false, error: new FgaError(err) }; 204 | } 205 | 206 | const status = (err as any)?.response?.status; 207 | const isNetworkError = !status; 208 | 209 | if (isNetworkError) { 210 | if (iterationCount > maxRetry) { 211 | return { retryable: false, error: new FgaError(err) }; 212 | } 213 | return { retryable: true }; 214 | } 215 | 216 | if (status === 400 || status === 422) { 217 | return { retryable: false, error: new FgaApiValidationError(err) }; 218 | } else if (status === 401 || status === 403) { 219 | return { retryable: false, error: new FgaApiAuthenticationError(err) }; 220 | } else if (status === 404) { 221 | return { retryable: false, error: new FgaApiNotFoundError(err) }; 222 | } else if (status === 429 || (status >= 500 && status !== 501)) { 223 | if (iterationCount > maxRetry) { 224 | if (status === 429) { 225 | return { retryable: false, error: new FgaApiRateLimitExceededError(err) }; 226 | } else { 227 | return { retryable: false, error: new FgaApiInternalError(err) }; 228 | } 229 | } 230 | return { retryable: true }; 231 | } else { 232 | return { retryable: false, error: new FgaApiError(err) }; 233 | } 234 | } 235 | 236 | export async function attemptHttpRequest( 237 | request: AxiosRequestConfig, 238 | config: { 239 | maxRetry: number; 240 | minWaitInMs: number; 241 | }, 242 | axiosInstance: AxiosInstance, 243 | ): Promise | undefined> { 244 | let iterationCount = 0; 245 | do { 246 | iterationCount++; 247 | try { 248 | const response = await axiosInstance(request); 249 | return { 250 | response: response, 251 | retries: iterationCount - 1, 252 | }; 253 | } catch (err: any) { 254 | const { retryable, error } = checkIfRetryableError(err, iterationCount, config.maxRetry); 255 | 256 | if (!retryable) { 257 | throw error; 258 | } 259 | 260 | const status = (err as any)?.response?.status; 261 | let retryDelayMs: number | undefined; 262 | 263 | if ((status && 264 | (status === 429 || (status >= 500 && status !== 501))) && 265 | err.response?.headers) { 266 | retryDelayMs = parseRetryAfterHeader(err.response.headers); 267 | } 268 | if (!retryDelayMs) { 269 | retryDelayMs = calculateExponentialBackoffWithJitter(iterationCount, config.minWaitInMs); 270 | } 271 | 272 | await new Promise(r => setTimeout(r, Math.min(retryDelayMs, SdkConstants.RetryHeaderMaxAllowableDurationInSec * 1000))); 273 | } 274 | } while (iterationCount < config.maxRetry + 1); 275 | } 276 | 277 | /** 278 | * creates an axios request function 279 | */ 280 | export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInstance: AxiosInstance, configuration: Configuration, credentials: Credentials, methodAttributes: Record = {}) { 281 | configuration.isValid(); 282 | 283 | const retryParams = axiosArgs.options?.retryParams ? axiosArgs.options?.retryParams : configuration.retryParams; 284 | const maxRetry: number = retryParams?.maxRetry ?? 0; 285 | const minWaitInMs: number = retryParams?.minWaitInMs ?? 0; 286 | 287 | const start = performance.now(); 288 | 289 | return async (axios: AxiosInstance = axiosInstance) : PromiseResult => { 290 | await setBearerAuthToObject(axiosArgs.options.headers, credentials!); 291 | 292 | const url = configuration.getBasePath() + axiosArgs.url; 293 | 294 | const axiosRequestArgs = {...axiosArgs.options, url: url}; 295 | const wrappedResponse = await attemptHttpRequest(axiosRequestArgs, { 296 | maxRetry, 297 | minWaitInMs, 298 | }, axios); 299 | const response = wrappedResponse?.response; 300 | const data = typeof response?.data === "undefined" ? {} : response?.data; 301 | const result: CallResult = { ...data }; 302 | setNotEnumerableProperty(result, "$response", response); 303 | 304 | let attributes: StringIndexable = {}; 305 | 306 | attributes = TelemetryAttributes.fromRequest({ 307 | userAgent: configuration.baseOptions?.headers["User-Agent"], 308 | httpMethod: axiosArgs.options?.method, 309 | url, 310 | resendCount: wrappedResponse?.retries, 311 | start: start, 312 | credentials: credentials, 313 | attributes: methodAttributes, 314 | }); 315 | 316 | attributes = TelemetryAttributes.fromResponse({ 317 | response, 318 | attributes, 319 | }); 320 | 321 | // only if hisogramQueryDuration set AND if response header contains fga-query-duration-ms 322 | const serverRequestDuration = attributes[TelemetryAttribute.HttpServerRequestDuration]; 323 | if (configuration.telemetry?.metrics?.histogramQueryDuration && typeof serverRequestDuration !== "undefined") { 324 | configuration.telemetry.recorder.histogram( 325 | TelemetryHistograms.queryDuration, 326 | parseInt(attributes[TelemetryAttribute.HttpServerRequestDuration] as string, 10), 327 | TelemetryAttributes.prepare( 328 | attributes, 329 | configuration.telemetry.metrics.histogramQueryDuration.attributes 330 | ) 331 | ); 332 | } 333 | 334 | if (configuration.telemetry?.metrics?.histogramRequestDuration) { 335 | configuration.telemetry.recorder.histogram( 336 | TelemetryHistograms.requestDuration, 337 | attributes[TelemetryAttribute.HttpClientRequestDuration], 338 | TelemetryAttributes.prepare( 339 | attributes, 340 | configuration.telemetry.metrics.histogramRequestDuration.attributes 341 | ) 342 | ); 343 | } 344 | 345 | return result; 346 | }; 347 | }; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.1...HEAD) 5 | 6 | ## v0.9.1 7 | 8 | ### [v0.9.1](https://github.com/openfga/js-sdk/compare/v0.9.0...v0.9.1) (2025-11-05) 9 | 10 | - feat: add support for handling Retry-After header (#267) 11 | - feat: add support for conflict options for Write operations: (#276) 12 | The client now supports setting `conflict` on `ClientWriteRequestOpts` to control behavior when writing duplicate tuples or deleting non-existent tuples. This feature requires OpenFGA server [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. 13 | See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more. 14 | 15 | ## v0.9.0 16 | 17 | ### [v0.9.0](https://github.com/openfga/js-sdk/compare/v0.8.1...v0.9.0) (2025-06-04) 18 | 19 | - feat: support client assertion for client credentials authentication (#228) 20 | 21 | ## v0.8.1 22 | 23 | ### [v0.8.1](https://github.com/openfga/js-sdk/compare/v0.8.0...v0.8.1) (2025-04-24) 24 | 25 | - fix: change check for Node.js environment to fix issue where `process.title` cannot be read (#222) 26 | 27 | ## v0.8.0 28 | 29 | ### [0.8.0](https://github.com/openfga/js-sdk/compare/v0.7.0...v0.8.0) (2025-01-14) 30 | 31 | - feat!: add support for server-side `BatchCheck` method. This is a more efficient way to check on multiple tuples than calling the existing client-side `BatchCheck`. Using this method requires an OpenFGA [v1.8.0+](https://github.com/openfga/openfga/releases/tag/v1.8.0) server. 32 | - The existing `BatchCheck` method has been renamed to `clientBatchCheck` and it now bundles the results in a field called `result` instead of `responses`. 33 | - The existing `BatchCheckResponse` has been renamed to `ClientBatchCheckResponse`. 34 | - feat: add support for startTime` parameter in `ReadChanges` endpoint 35 | - feat: support contextual tuples and context in assertions 36 | - feat: support contextual tuples in Expand 37 | - fix: error correctly if apiUrl is not provided - thanks @Waheedsys (#161) 38 | - fix: use provided axios instance in credentials refresh - thanks @Siddhant-K-code (#193) 39 | - fix!: The minimum node version required by this SDK is now v16.15.0 40 | - chore(docs): various cleanup and improvements - thanks @tmsagarofficial (#164), @vil02 (https://github.com/openfga/sdk-generator/pull/424, https://github.com/openfga/sdk-generator/pull/422), @sccalabr (https://github.com/openfga/sdk-generator/pull/433) 41 | 42 | BREAKING CHANGES: 43 | - The minimum node version required by this SDK is now v16.15.0 44 | - Usage of the existing `batchCheck` method should now use the `clientBatchCheck` method. The existing `BatchCheckResponse` has been renamed to `ClientBatchCheckResponse` and it now bundles the results in a field called `result` instead of `responses`. 45 | 46 | ## v0.7.0 47 | 48 | ### [0.7.0](https://github.com/openfga/js-sdk/compare/v0.6.3...v0.7.0) (2024-08-30) 49 | 50 | - feat!: enhancements to OpenTelemetry support (#149) 51 | 52 | BREAKING CHANGE: 53 | 54 | This version changes the way in which telemetry is configured and reported. See #149 for additional information. 55 | 56 | ## v0.6.3 57 | 58 | ### [0.6.3](https://github.com/openfga/js-sdk/compare/v0.6.2...v0.6.3) (2024-08-28) 59 | 60 | - fix: set the consistency parameter correctly in OpenFgaClient (#143) 61 | 62 | ## v0.6.2 63 | 64 | ### [0.6.2](https://github.com/openfga/js-sdk/compare/v0.6.1...v0.6.2) (2024-07-31) 65 | - feat: add support for specifying consistency when evaluating or reading (#129) 66 | Note: To use this feature, you need to be running OpenFGA v1.5.7+ with the experimental flag 67 | `enable-consistency-params` enabled. See the [v1.5.7 release notes](https://github.com/openfga/openfga/releases/tag/v1.5.7) for details. 68 | 69 | ## v0.6.1 70 | 71 | ### [0.6.1](https://github.com/openfga/js-sdk/compare/v0.6.0...v0.6.1) (2024-07-11) 72 | - fix(metrics): add missing request model id attribute (#122) 73 | 74 | > [!IMPORTANT] 75 | > In this release we have changed our TypeScript compile target to ES2020 to align with our stated supported environments 76 | 77 | ## v0.6.0 78 | 79 | ### [0.6.0](https://github.com/openfga/js-sdk/compare/v0.5.0...v0.6.0) (2024-06-28) 80 | - feat: add opentelemetry metrics reporting (#117) 81 | 82 | ## v0.5.0 83 | 84 | ### [0.5.0](https://github.com/openfga/js-sdk/compare/v0.4.0...v0.5.0) (2024-06-14) 85 | - chore!: remove excluded users from ListUsers response 86 | 87 | BREAKING CHANGE: 88 | 89 | This version removes the `excluded_users` property from the `ListUsersResponse` and `ClientListUsersResponse` interfaces, 90 | for more details see the [associated API change](https://github.com/openfga/api/pull/171). 91 | 92 | ## v0.4.0 93 | 94 | ### [0.4.0](https://github.com/openfga/js-sdk/compare/v0.3.5...v0.4.0) (2024-04-30) 95 | 96 | - feat: support the [ListUsers](https://github.com/openfga/rfcs/blob/main/20231214-listUsers-api.md) endpoint (#97) 97 | - feat!: support overriding storeId per request (#97) 98 | `OpenFgaClient` now supports specifying the storeId in the options to override it per request 99 | 100 | [BREAKING CHANGE] the underlying `OpenFgaApi` now expects `storeId` as the first param on relevant methods, 101 | if you are still using this class, make sure you update your references when needed. 102 | 103 | ## v0.3.5 104 | 105 | ### [0.3.5](https://github.com/openfga/js-sdk/compare/v0.3.4...v0.3.5) (2024-03-19) 106 | 107 | - feat: add support for modular models metadata 108 | 109 | ## v0.3.4 110 | 111 | ### [0.3.4](https://github.com/openfga/js-sdk/compare/v0.3.3...v0.3.4) (2024-03-15) 112 | 113 | - chore: bump deps. resolves [CVE-2024-28849](https://nvd.nist.gov/vuln/detail/CVE-2024-28849) in 114 | [follow-redirects](https://www.npmjs.com/package/follow-redirects) 115 | 116 | ## v0.3.3 117 | 118 | ### [0.3.3](https://github.com/openfga/js-sdk/compare/v0.3.2...v0.3.3) (2024-02-26) 119 | 120 | - fix: do not call ReadAuthorizationModel on BatchCheck or non-Transactional Write 121 | 122 | ## v0.3.2 123 | 124 | ### [0.3.2](https://github.com/openfga/js-sdk/compare/v0.3.1...v0.3.2) (2024-02-13) 125 | 126 | - feat: add example project 127 | - feat: add support for `apiUrl` configuration option and deprecate `apiScheme` and `apiHost` 128 | - fix: use correct content type for token request 129 | - fix: make body in `readChanges` optional 130 | 131 | ## v0.3.1 132 | 133 | ### [0.3.1](https://github.com/openfga/js-sdk/compare/v0.3.0...v0.3.1) (2024-01-26) 134 | 135 | - chore: use latest API interfaces 136 | - chore: dependency updates 137 | 138 | ## v0.3.0 139 | 140 | ### [0.3.0](https://github.com/openfga/js-sdk/compare/v0.2.10...v0.3.0) (2023-12-11) 141 | 142 | - feat: support for [conditions](https://openfga.dev/blog/conditional-tuples-announcement) 143 | - chore: use latest API interfaces 144 | - chore: dependency updates 145 | 146 | ## v0.2.10 147 | 148 | ### [0.2.10](https://github.com/openfga/js-sdk/compare/v0.2.9...v0.2.10) (2023-11-01) 149 | 150 | - chore(deps): update dependencies 151 | updates axios to `^1.6.0` to resolve [SNYK-JS-AXIOS-6032459](https://security.snyk.io/vuln/SNYK-JS-AXIOS-6032459) 152 | 153 | ## v0.2.9 154 | 155 | ### [0.2.9](https://github.com/openfga/js-sdk/compare/v0.2.8...v0.2.9) (2023-10-20) 156 | 157 | - chore(deps): update dependencies 158 | 159 | ## v0.2.8 160 | 161 | ### [0.2.8](https://github.com/openfga/js-sdk/compare/v0.2.7...v0.2.8) (2023-08-18) 162 | 163 | - fix: set http keep-alive to true 164 | - fix: list relations should throw when an underlying check errors 165 | - fix: return raw response in client check 166 | - chore(deps): update dependencies 167 | 168 | ## v0.2.7 169 | 170 | ### [0.2.7](https://github.com/openfga/js-sdk/compare/v0.2.6...v0.2.7) (2023-08-16) 171 | 172 | - fix(credentials): fix calculation of token expiry 173 | - chore(deps): update dependencies 174 | 175 | ## v0.2.6 176 | 177 | ### [0.2.6](https://github.com/openfga/js-sdk/compare/v0.2.5...v0.2.6) (2023-05-19) 178 | 179 | - feat(validation): ensure storeId and authorizationModelId are in valid ulid format 180 | - fix(client): ensure that the api connection is valid 181 | - fix(credentials): retry on client credential exchange in case of errors 182 | - chore(deps): update dependencies 183 | 184 | ## v0.2.5 185 | 186 | ### [0.2.5](https://github.com/openfga/js-sdk/compare/v0.2.4...v0.2.5) (2023-04-21) 187 | 188 | - feat(client): implement `listRelations` to check what relationships a user has with an object 189 | - feat!: `schema_version` is now required when calling `WriteAuthorizationModel` 190 | - fix(client): proper parallel limit for batch fns (BatchCheck, etc..) 191 | - chore(ci): publish provenance data 192 | - chore(deps): update dependencies 193 | 194 | ## v0.2.4 195 | 196 | ### [0.2.4](https://github.com/openfga/js-sdk/compare/v0.2.3...v0.2.4) (2023-03-09) 197 | 198 | - fix(client): OpenFgaClient `read` was not passing in pagination options 199 | - feat(client): implement sleep in batch calls to lower the possibility of hitting rate limits 200 | 201 | ## v0.2.3 202 | 203 | ### [0.2.3](https://github.com/openfga/js-sdk/compare/v0.2.2...v0.2.3) (2023-03-07) 204 | 205 | - feat(client): client wrapper with a slightly changed interface 206 | - feat(client): implement `batchCheck` to check multiple tuples in parallel 207 | - feat(client): add support for a non-transactional `Write` 208 | - chore(config): bump default max retries to 5 209 | - fix: retry on 5xx errors 210 | - chore!: request Node >= 14.7.0 211 | 212 | Checkout the [README](https://github.com/openfga/js-sdk/blob/main/README.md) for more on how to use the new OpenFgaClient. 213 | 214 | ## v0.2.2 215 | 216 | ### [0.2.2](https://github.com/openfga/js-sdk/compare/v0.2.1...v0.2.2) (2023-01-23) 217 | 218 | - fix(credentials): resolve client credentials token not being cached 219 | - chore(deps): upgrade dev dependencies 220 | 221 | ## v0.2.1 222 | 223 | ### [0.2.1](https://github.com/openfga/js-sdk/compare/v0.2.0...v0.2.1) (2023-01-17) 224 | 225 | - chore(deps): upgrade dev dependencies, resolves npm audit issue 226 | 227 | ## v0.2.0 228 | 229 | ### [0.2.0](https://github.com/openfga/js-sdk/compare/v0.1.1...v0.2.0) (2022-12-14) 230 | 231 | Updated to include support for [OpenFGA 0.3.0](https://github.com/openfga/openfga/releases/tag/v0.3.0) 232 | 233 | Changes: 234 | - [BREAKING] feat(list-objects)!: response has been changed to include the object type 235 | e.g. response that was `{"object_ids":["roadmap"]}`, will now be `{"objects":["document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"]}` 236 | 237 | Fixes: 238 | - fix(models): update interfaces that had incorrectly optional fields to make them required 239 | 240 | Chore: 241 | - chore(deps): update dev dependencies 242 | 243 | ## v0.1.1 244 | 245 | ### [0.1.1](https://github.com/openfga/js-sdk/compare/v0.1.0...v0.1.1) (2022-11-15) 246 | 247 | Regenerate to include support for [restricting wildcards](https://github.com/openfga/rfcs/pull/8) in authorization models. 248 | 249 | ## v0.1.0 250 | 251 | ### [0.1.0](https://github.com/openfga/js-sdk/compare/v0.0.2...v0.1.0) (2022-09-29) 252 | 253 | - BREAKING: exported type `TypeDefinitions` is now `WriteAuthorizationModelRequest` 254 | This is only a breaking change on the SDK, not the API. It was changed to conform to the proto changes in [openfga/api](https://github.com/openfga/api/pull/27). 255 | It makes the type name more consistent and less confusing (normally people would incorrectly assume TypeDefinitions = TypeDefinition[]). 256 | - chore(deps): upgrade dependencies 257 | 258 | ## v0.0.2 259 | 260 | ### [0.0.2](https://github.com/openfga/js-sdk/compare/v0.0.1...v0.0.2) (2022-08-15) 261 | 262 | Support for [ListObjects API]](https://openfga.dev/api/service#/Relationship%20Queries/ListObjects) 263 | 264 | You call the API and receive the list of object ids from a particular type that the user has a certain relation with. 265 | 266 | For example, to find the list of documents that Anne can read: 267 | 268 | ```javascript 269 | const response = await openFgaApi.listObjects({ 270 | user: "anne", 271 | relation: "can_read", 272 | type: "document" 273 | }); 274 | 275 | // response.object_ids = ["roadmap"] 276 | ``` 277 | 278 | ## v0.0.1 279 | 280 | ### [0.0.1](https://github.com/openfga/js-sdk/releases/tag/v0.0.1) (2022-06-15) 281 | 282 | Initial OpenFGA JS SDK release 283 | - Support for [OpenFGA](https://github.com/openfga/openfga) API 284 | - CRUD stores 285 | - Create, read & list authorization models 286 | - Writing and Reading Tuples 287 | - Checking authorization 288 | - Using Expand to understand why access was granted 289 | -------------------------------------------------------------------------------- /tests/headers.test.ts: -------------------------------------------------------------------------------- 1 | import * as nock from "nock"; 2 | import { OpenFgaClient, UserClientConfigurationParams } from "../index"; 3 | import { baseConfig } from "./helpers/default-config"; 4 | import { CredentialsMethod } from "../credentials"; 5 | 6 | nock.disableNetConnect(); 7 | 8 | describe("Header Functionality Tests", () => { 9 | const testConfig: UserClientConfigurationParams = { 10 | ...baseConfig, 11 | credentials: { method: CredentialsMethod.None } 12 | }; 13 | 14 | afterEach(() => { 15 | nock.cleanAll(); 16 | }); 17 | 18 | describe("Default headers from client configuration", () => { 19 | it("should send default headers from baseOptions on all requests", async () => { 20 | const fgaClient = new OpenFgaClient({ 21 | ...testConfig, 22 | baseOptions: { 23 | headers: { 24 | "X-Default-Header": "default-value", 25 | "X-Client-ID": "test-client-123", 26 | "X-API-Version": "v1.0" 27 | } 28 | } 29 | }); 30 | 31 | const scope = nock(testConfig.apiUrl!) 32 | .post(`/stores/${testConfig.storeId}/check`) 33 | .reply(function() { 34 | // Verify all default headers are present 35 | expect(this.req.headers["x-default-header"]).toBe("default-value"); 36 | expect(this.req.headers["x-client-id"]).toBe("test-client-123"); 37 | expect(this.req.headers["x-api-version"]).toBe("v1.0"); 38 | return [200, { allowed: true }]; 39 | }); 40 | 41 | await fgaClient.check({ 42 | user: "user:test", 43 | relation: "reader", 44 | object: "document:test" 45 | }); 46 | 47 | expect(scope.isDone()).toBe(true); 48 | }); 49 | 50 | it("should send default headers on multiple different API calls", async () => { 51 | const fgaClient = new OpenFgaClient({ 52 | ...testConfig, 53 | baseOptions: { 54 | headers: { 55 | "X-Persistent-Header": "should-appear-everywhere" 56 | } 57 | } 58 | }); 59 | 60 | // Test check endpoint 61 | const checkScope = nock(testConfig.apiUrl!) 62 | .post(`/stores/${testConfig.storeId}/check`) 63 | .reply(function() { 64 | expect(this.req.headers["x-persistent-header"]).toBe("should-appear-everywhere"); 65 | return [200, { allowed: true }]; 66 | }); 67 | 68 | // Test read endpoint 69 | const readScope = nock(testConfig.apiUrl!) 70 | .post(`/stores/${testConfig.storeId}/read`) 71 | .reply(function() { 72 | expect(this.req.headers["x-persistent-header"]).toBe("should-appear-everywhere"); 73 | return [200, { tuples: [] }]; 74 | }); 75 | 76 | await fgaClient.check({ 77 | user: "user:test", 78 | relation: "reader", 79 | object: "document:test" 80 | }); 81 | 82 | await fgaClient.read({}); 83 | 84 | expect(checkScope.isDone()).toBe(true); 85 | expect(readScope.isDone()).toBe(true); 86 | }); 87 | }); 88 | 89 | describe("Per-request headers", () => { 90 | it("should send per-request headers when specified", async () => { 91 | const fgaClient = new OpenFgaClient(testConfig); 92 | 93 | const scope = nock(testConfig.apiUrl!) 94 | .post(`/stores/${testConfig.storeId}/check`) 95 | .reply(function() { 96 | expect(this.req.headers["x-request-header"]).toBe("request-value"); 97 | expect(this.req.headers["x-correlation-id"]).toBe("abc-123-def"); 98 | return [200, { allowed: true }]; 99 | }); 100 | 101 | await fgaClient.check({ 102 | user: "user:test", 103 | relation: "reader", 104 | object: "document:test" 105 | }, { 106 | headers: { 107 | "X-Request-Header": "request-value", 108 | "X-Correlation-ID": "abc-123-def" 109 | } 110 | }); 111 | 112 | expect(scope.isDone()).toBe(true); 113 | }); 114 | 115 | it("should only send per-request headers on the specific request", async () => { 116 | const fgaClient = new OpenFgaClient(testConfig); 117 | 118 | // First request with headers 119 | const firstScope = nock(testConfig.apiUrl!) 120 | .post(`/stores/${testConfig.storeId}/check`) 121 | .reply(function() { 122 | expect(this.req.headers["x-first-request"]).toBe("first-value"); 123 | expect(this.req.headers["x-second-request"]).toBeUndefined(); 124 | return [200, { allowed: true }]; 125 | }); 126 | 127 | // Second request with different headers 128 | const secondScope = nock(testConfig.apiUrl!) 129 | .post(`/stores/${testConfig.storeId}/check`) 130 | .reply(function() { 131 | expect(this.req.headers["x-second-request"]).toBe("second-value"); 132 | expect(this.req.headers["x-first-request"]).toBeUndefined(); 133 | return [200, { allowed: true }]; 134 | }); 135 | 136 | await fgaClient.check({ 137 | user: "user:test", 138 | relation: "reader", 139 | object: "document:test" 140 | }, { 141 | headers: { 142 | "X-First-Request": "first-value" 143 | } 144 | }); 145 | 146 | await fgaClient.check({ 147 | user: "user:different", 148 | relation: "writer", 149 | object: "document:other" 150 | }, { 151 | headers: { 152 | "X-Second-Request": "second-value" 153 | } 154 | }); 155 | 156 | expect(firstScope.isDone()).toBe(true); 157 | expect(secondScope.isDone()).toBe(true); 158 | }); 159 | }); 160 | 161 | describe("Default + per-request header combination", () => { 162 | it("should send both default headers and per-request headers", async () => { 163 | const fgaClient = new OpenFgaClient({ 164 | ...testConfig, 165 | baseOptions: { 166 | headers: { 167 | "X-Default-Header": "default-value", 168 | "X-Client-Name": "test-client" 169 | } 170 | } 171 | }); 172 | 173 | const scope = nock(testConfig.apiUrl!) 174 | .post(`/stores/${testConfig.storeId}/check`) 175 | .reply(function() { 176 | // Verify default headers are present 177 | expect(this.req.headers["x-default-header"]).toBe("default-value"); 178 | expect(this.req.headers["x-client-name"]).toBe("test-client"); 179 | 180 | // Verify per-request headers are present 181 | expect(this.req.headers["x-request-id"]).toBe("req-123"); 182 | expect(this.req.headers["x-user-context"]).toBe("test-user"); 183 | 184 | return [200, { allowed: true }]; 185 | }); 186 | 187 | await fgaClient.check({ 188 | user: "user:test", 189 | relation: "reader", 190 | object: "document:test" 191 | }, { 192 | headers: { 193 | "X-Request-ID": "req-123", 194 | "X-User-Context": "test-user" 195 | } 196 | }); 197 | 198 | expect(scope.isDone()).toBe(true); 199 | }); 200 | 201 | it("should merge headers from multiple sources correctly", async () => { 202 | const fgaClient = new OpenFgaClient({ 203 | ...testConfig, 204 | baseOptions: { 205 | headers: { 206 | "X-Source": "default", 207 | "X-Default-Only": "only-in-default", 208 | "X-Version": "1.0" 209 | } 210 | } 211 | }); 212 | 213 | const scope = nock(testConfig.apiUrl!) 214 | .post(`/stores/${testConfig.storeId}/check`) 215 | .reply(function() { 216 | const headers = this.req.headers; 217 | 218 | // Default headers should be present 219 | expect(headers["x-source"]).toBe("default"); 220 | expect(headers["x-default-only"]).toBe("only-in-default"); 221 | expect(headers["x-version"]).toBe("1.0"); 222 | 223 | // Per-request headers should be present 224 | expect(headers["x-request-only"]).toBe("only-in-request"); 225 | expect(headers["x-timestamp"]).toBe("2023-10-01"); 226 | 227 | // SDK headers should be present 228 | expect(headers["content-type"]).toBe("application/json"); 229 | expect(headers["user-agent"]).toMatch(/openfga-sdk/); 230 | 231 | return [200, { allowed: true }]; 232 | }); 233 | 234 | await fgaClient.check({ 235 | user: "user:test", 236 | relation: "reader", 237 | object: "document:test" 238 | }, { 239 | headers: { 240 | "X-Request-Only": "only-in-request", 241 | "X-Timestamp": "2023-10-01" 242 | } 243 | }); 244 | 245 | expect(scope.isDone()).toBe(true); 246 | }); 247 | }); 248 | 249 | describe("Header precedence and override behavior", () => { 250 | it("should allow per-request headers to override default headers", async () => { 251 | const fgaClient = new OpenFgaClient({ 252 | ...testConfig, 253 | baseOptions: { 254 | headers: { 255 | "X-Environment": "default-env", 256 | "X-Priority": "low", 257 | "X-Shared-Header": "from-default" 258 | } 259 | } 260 | }); 261 | 262 | const scope = nock(testConfig.apiUrl!) 263 | .post(`/stores/${testConfig.storeId}/check`) 264 | .reply(function() { 265 | // Per-request headers should override default headers 266 | expect(this.req.headers["x-environment"]).toBe("production"); 267 | expect(this.req.headers["x-priority"]).toBe("high"); 268 | expect(this.req.headers["x-shared-header"]).toBe("from-request"); 269 | 270 | return [200, { allowed: true }]; 271 | }); 272 | 273 | await fgaClient.check({ 274 | user: "user:test", 275 | relation: "reader", 276 | object: "document:test" 277 | }, { 278 | headers: { 279 | "X-Environment": "production", 280 | "X-Priority": "high", 281 | "X-Shared-Header": "from-request" 282 | } 283 | }); 284 | 285 | expect(scope.isDone()).toBe(true); 286 | }); 287 | 288 | it("should preserve non-overridden default headers", async () => { 289 | const fgaClient = new OpenFgaClient({ 290 | ...testConfig, 291 | baseOptions: { 292 | headers: { 293 | "X-Keep-Default": "keep-this", 294 | "X-Override-This": "original-value", 295 | "X-Also-Keep": "also-keep-this" 296 | } 297 | } 298 | }); 299 | 300 | const scope = nock(testConfig.apiUrl!) 301 | .post(`/stores/${testConfig.storeId}/check`) 302 | .reply(function() { 303 | // Non-overridden defaults should remain 304 | expect(this.req.headers["x-keep-default"]).toBe("keep-this"); 305 | expect(this.req.headers["x-also-keep"]).toBe("also-keep-this"); 306 | 307 | // Overridden header should have new value 308 | expect(this.req.headers["x-override-this"]).toBe("new-value"); 309 | 310 | return [200, { allowed: true }]; 311 | }); 312 | 313 | await fgaClient.check({ 314 | user: "user:test", 315 | relation: "reader", 316 | object: "document:test" 317 | }, { 318 | headers: { 319 | "X-Override-This": "new-value" 320 | } 321 | }); 322 | 323 | expect(scope.isDone()).toBe(true); 324 | }); 325 | 326 | it("should handle case-insensitive header overrides correctly", async () => { 327 | const fgaClient = new OpenFgaClient({ 328 | ...testConfig, 329 | baseOptions: { 330 | headers: { 331 | "X-Test-Header": "default-value" 332 | } 333 | } 334 | }); 335 | 336 | const scope = nock(testConfig.apiUrl!) 337 | .post(`/stores/${testConfig.storeId}/check`) 338 | .reply(function() { 339 | // HTTP headers are case-insensitive, so request header should override default 340 | const testHeaderValue = this.req.headers["x-test-header"]; 341 | 342 | // Per-request should win 343 | expect(testHeaderValue).toBe("request-value"); 344 | 345 | return [200, { allowed: true }]; 346 | }); 347 | 348 | await fgaClient.check({ 349 | user: "user:test", 350 | relation: "reader", 351 | object: "document:test" 352 | }, { 353 | headers: { 354 | "x-test-header": "request-value" // Different case 355 | } 356 | }); 357 | 358 | expect(scope.isDone()).toBe(true); 359 | }); 360 | }); 361 | 362 | describe("Content-Type header protection behavior", () => { 363 | it("does not honor Content-Type header from baseOptions override", async () => { 364 | // The SDK protects Content-Type 365 | // User attempts to set Content-Type via baseOptions are ignored 366 | 367 | const fgaClient = new OpenFgaClient({ 368 | ...testConfig, 369 | baseOptions: { 370 | headers: { 371 | "Content-Type": "text/plain", // SDK ignores this 372 | "X-Custom-Header": "should-work" // Custom headers work fine 373 | } 374 | } 375 | }); 376 | 377 | const scope = nock(testConfig.apiUrl!) 378 | .post(`/stores/${testConfig.storeId}/check`) 379 | .reply(function() { 380 | const headers = this.req.headers; 381 | 382 | // SDK enforces Content-Type for JSON APIs 383 | expect(headers["content-type"]).toBe("application/json"); 384 | 385 | // Custom headers are preserved 386 | expect(headers["x-custom-header"]).toBe("should-work"); 387 | 388 | return [200, { allowed: true }]; 389 | }); 390 | 391 | await fgaClient.check({ 392 | user: "user:test", 393 | relation: "reader", 394 | object: "document:test" 395 | }); 396 | 397 | expect(scope.isDone()).toBe(true); 398 | }); 399 | 400 | it("allows Content-Type override via per-request headers", async () => { 401 | // At the request level, user headers can override SDK headers 402 | 403 | const fgaClient = new OpenFgaClient(testConfig); 404 | 405 | const scope = nock(testConfig.apiUrl!) 406 | .post(`/stores/${testConfig.storeId}/check`) 407 | .reply(function() { 408 | // Per-request headers override SDK headers (including Content-Type) 409 | expect(this.req.headers["content-type"]).toBe("application/xml"); 410 | expect(this.req.headers["x-custom-request"]).toBe("request-value"); 411 | 412 | return [200, { allowed: true }]; 413 | }); 414 | 415 | await fgaClient.check({ 416 | user: "user:test", 417 | relation: "reader", 418 | object: "document:test" 419 | }, { 420 | headers: { 421 | "Content-Type": "application/xml", // This overrides SDK's Content-Type 422 | "X-Custom-Request": "request-value" // Custom headers also work 423 | } 424 | }); 425 | 426 | expect(scope.isDone()).toBe(true); 427 | }); 428 | 429 | it("should set Content-Type to application/json by default", async () => { 430 | // When no Content-Type is specified, SDK sets it to application/json 431 | 432 | const fgaClient = new OpenFgaClient({ 433 | ...testConfig, 434 | baseOptions: { 435 | headers: { 436 | "X-API-Version": "v1", // Custom header without Content-Type 437 | "Authorization": "Bearer token" // Another custom header 438 | } 439 | } 440 | }); 441 | 442 | const scope = nock(testConfig.apiUrl!) 443 | .post(`/stores/${testConfig.storeId}/check`) 444 | .reply(function() { 445 | const headers = this.req.headers; 446 | 447 | // SDK automatically sets Content-Type for JSON APIs 448 | expect(headers["content-type"]).toBe("application/json"); 449 | 450 | // Custom headers are preserved 451 | expect(headers["x-api-version"]).toBe("v1"); 452 | expect(headers["authorization"]).toBe("Bearer token"); 453 | 454 | return [200, { allowed: true }]; 455 | }); 456 | 457 | await fgaClient.check({ 458 | user: "user:test", 459 | relation: "reader", 460 | object: "document:test" 461 | }); 462 | 463 | expect(scope.isDone()).toBe(true); 464 | }); 465 | 466 | it("should demonstrate Content-Type protection is only critical header", async () => { 467 | // Only Content-Type receives special protection - other SDK headers can be overridden 468 | 469 | const fgaClient = new OpenFgaClient({ 470 | ...testConfig, 471 | baseOptions: { 472 | headers: { 473 | "Content-Type": "text/plain", // SDK ignores this 474 | "User-Agent": "custom-agent", // This might work (not protected) 475 | "Accept": "text/html", // This might work (not protected) 476 | "X-Custom": "definitely-works" // Custom headers always work 477 | } 478 | } 479 | }); 480 | 481 | const scope = nock(testConfig.apiUrl!) 482 | .post(`/stores/${testConfig.storeId}/check`) 483 | .reply(function() { 484 | const headers = this.req.headers; 485 | 486 | // Only Content-Type is strictly protected 487 | expect(headers["content-type"]).toBe("application/json"); 488 | 489 | // Other headers may or may not be overrideable (depends on axios behavior) 490 | // The key point is that only Content-Type has explicit SDK protection 491 | 492 | // Custom headers definitely work 493 | expect(headers["x-custom"]).toBe("definitely-works"); 494 | 495 | return [200, { allowed: true }]; 496 | }); 497 | 498 | await fgaClient.check({ 499 | user: "user:test", 500 | relation: "reader", 501 | object: "document:test" 502 | }); 503 | 504 | expect(scope.isDone()).toBe(true); 505 | }); 506 | }); 507 | 508 | describe("Edge cases and special scenarios", () => { 509 | it("should handle empty baseOptions headers", async () => { 510 | const fgaClient = new OpenFgaClient({ 511 | ...testConfig, 512 | baseOptions: { 513 | headers: {} 514 | } 515 | }); 516 | 517 | const scope = nock(testConfig.apiUrl!) 518 | .post(`/stores/${testConfig.storeId}/check`) 519 | .reply(function() { 520 | // Should still have SDK headers 521 | expect(this.req.headers["content-type"]).toBe("application/json"); 522 | expect(this.req.headers["user-agent"]).toMatch(/openfga-sdk/); 523 | 524 | return [200, { allowed: true }]; 525 | }); 526 | 527 | await fgaClient.check({ 528 | user: "user:test", 529 | relation: "reader", 530 | object: "document:test" 531 | }); 532 | 533 | expect(scope.isDone()).toBe(true); 534 | }); 535 | 536 | it("should handle undefined baseOptions", async () => { 537 | const fgaClient = new OpenFgaClient({ 538 | ...testConfig 539 | // No baseOptions specified 540 | }); 541 | 542 | const scope = nock(testConfig.apiUrl!) 543 | .post(`/stores/${testConfig.storeId}/check`) 544 | .reply(function() { 545 | // Should still have SDK headers 546 | expect(this.req.headers["content-type"]).toBe("application/json"); 547 | expect(this.req.headers["user-agent"]).toMatch(/openfga-sdk/); 548 | 549 | return [200, { allowed: true }]; 550 | }); 551 | 552 | await fgaClient.check({ 553 | user: "user:test", 554 | relation: "reader", 555 | object: "document:test" 556 | }); 557 | 558 | expect(scope.isDone()).toBe(true); 559 | }); 560 | 561 | it("should handle empty per-request headers", async () => { 562 | const fgaClient = new OpenFgaClient({ 563 | ...testConfig, 564 | baseOptions: { 565 | headers: { 566 | "X-Default": "default-value" 567 | } 568 | } 569 | }); 570 | 571 | const scope = nock(testConfig.apiUrl!) 572 | .post(`/stores/${testConfig.storeId}/check`) 573 | .reply(function() { 574 | // Default headers should still be present 575 | expect(this.req.headers["x-default"]).toBe("default-value"); 576 | 577 | return [200, { allowed: true }]; 578 | }); 579 | 580 | await fgaClient.check({ 581 | user: "user:test", 582 | relation: "reader", 583 | object: "document:test" 584 | }, { 585 | headers: {} // Empty headers object 586 | }); 587 | 588 | expect(scope.isDone()).toBe(true); 589 | }); 590 | 591 | it("should handle special header values", async () => { 592 | const fgaClient = new OpenFgaClient({ 593 | ...testConfig, 594 | baseOptions: { 595 | headers: { 596 | "X-Empty-String": "", 597 | "X-Number-Value": "123", 598 | "X-Boolean-Value": "true", 599 | "X-Special-Chars": "test@#$%^&*()_+-={}[]|\\:;\"'<>,.?/" 600 | } 601 | } 602 | }); 603 | 604 | const scope = nock(testConfig.apiUrl!) 605 | .post(`/stores/${testConfig.storeId}/check`) 606 | .reply(function() { 607 | const headers = this.req.headers; 608 | 609 | expect(headers["x-empty-string"]).toBe(""); 610 | expect(headers["x-number-value"]).toBe("123"); 611 | expect(headers["x-boolean-value"]).toBe("true"); 612 | expect(headers["x-special-chars"]).toBe("test@#$%^&*()_+-={}[]|\\:;\"'<>,.?/"); 613 | 614 | return [200, { allowed: true }]; 615 | }); 616 | 617 | await fgaClient.check({ 618 | user: "user:test", 619 | relation: "reader", 620 | object: "document:test" 621 | }); 622 | 623 | expect(scope.isDone()).toBe(true); 624 | }); 625 | 626 | it("should handle large number of headers", async () => { 627 | const defaultHeaders: Record = {}; 628 | const requestHeaders: Record = {}; 629 | 630 | // Create many default headers 631 | for (let i = 1; i <= 50; i++) { 632 | defaultHeaders[`X-Default-${i}`] = `default-value-${i}`; 633 | } 634 | 635 | // Create many request headers 636 | for (let i = 1; i <= 50; i++) { 637 | requestHeaders[`X-Request-${i}`] = `request-value-${i}`; 638 | } 639 | 640 | const fgaClient = new OpenFgaClient({ 641 | ...testConfig, 642 | baseOptions: { 643 | headers: defaultHeaders 644 | } 645 | }); 646 | 647 | const scope = nock(testConfig.apiUrl!) 648 | .post(`/stores/${testConfig.storeId}/check`) 649 | .reply(function() { 650 | const headers = this.req.headers; 651 | 652 | // Verify a sample of default headers 653 | expect(headers["x-default-1"]).toBe("default-value-1"); 654 | expect(headers["x-default-25"]).toBe("default-value-25"); 655 | expect(headers["x-default-50"]).toBe("default-value-50"); 656 | 657 | // Verify a sample of request headers 658 | expect(headers["x-request-1"]).toBe("request-value-1"); 659 | expect(headers["x-request-25"]).toBe("request-value-25"); 660 | expect(headers["x-request-50"]).toBe("request-value-50"); 661 | 662 | return [200, { allowed: true }]; 663 | }); 664 | 665 | await fgaClient.check({ 666 | user: "user:test", 667 | relation: "reader", 668 | object: "document:test" 669 | }, { 670 | headers: requestHeaders 671 | }); 672 | 673 | expect(scope.isDone()).toBe(true); 674 | }); 675 | }); 676 | 677 | describe("Header behavior across different API methods", () => { 678 | it("should send headers consistently across different API endpoints", async () => { 679 | const fgaClient = new OpenFgaClient({ 680 | ...testConfig, 681 | baseOptions: { 682 | headers: { 683 | "X-Consistent-Header": "always-present" 684 | } 685 | } 686 | }); 687 | 688 | // Test multiple endpoints 689 | const checkScope = nock(testConfig.apiUrl!) 690 | .post(`/stores/${testConfig.storeId}/check`) 691 | .reply(function() { 692 | expect(this.req.headers["x-consistent-header"]).toBe("always-present"); 693 | return [200, { allowed: true }]; 694 | }); 695 | 696 | const readScope = nock(testConfig.apiUrl!) 697 | .post(`/stores/${testConfig.storeId}/read`) 698 | .reply(function() { 699 | expect(this.req.headers["x-consistent-header"]).toBe("always-present"); 700 | return [200, { tuples: [] }]; 701 | }); 702 | 703 | const writeScope = nock(testConfig.apiUrl!) 704 | .post(`/stores/${testConfig.storeId}/write`) 705 | .reply(function() { 706 | expect(this.req.headers["x-consistent-header"]).toBe("always-present"); 707 | return [200, {}]; 708 | }); 709 | 710 | await fgaClient.check({ 711 | user: "user:test", 712 | relation: "reader", 713 | object: "document:test" 714 | }); 715 | 716 | await fgaClient.read({}); 717 | 718 | await fgaClient.write({ 719 | writes: [{ 720 | user: "user:test", 721 | relation: "reader", 722 | object: "document:test" 723 | }] 724 | }); 725 | 726 | expect(checkScope.isDone()).toBe(true); 727 | expect(readScope.isDone()).toBe(true); 728 | expect(writeScope.isDone()).toBe(true); 729 | }); 730 | }); 731 | }); 732 | --------------------------------------------------------------------------------