From 0077d28b5774e7e7cd2ad6c4dbcc3515d11d7726 Mon Sep 17 00:00:00 2001 From: shaibuuneks Date: Fri, 17 Oct 2025 12:19:58 +0000 Subject: [PATCH 1/6] test(headers): allow local HTTPS domain in Nock setup --- tests/headers.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/headers.test.ts b/tests/headers.test.ts index 73c2d6d..035c1c5 100644 --- a/tests/headers.test.ts +++ b/tests/headers.test.ts @@ -24,6 +24,8 @@ describe("Header Functionality Tests", () => { credentials: { method: CredentialsMethod.None } }; + + afterEach(() => { nock.cleanAll(); }); From 02c8184a9da31eaacb0607393f84146288268253 Mon Sep 17 00:00:00 2001 From: shaibuuneks Date: Fri, 17 Oct 2025 12:19:58 +0000 Subject: [PATCH 2/6] feat: support custom OIDC token endpoint paths --- CHANGELOG.md | 8 ++ README.md | 206 +++++++++++++++++++++++++++++++++ credentials/credentials.ts | 76 +++++++++--- tests/credentials-oidc.test.ts | 169 +++++++++++++++++++++++++++ tests/headers.test.ts | 48 ++++---- 5 files changed, 466 insertions(+), 41 deletions(-) create mode 100644 tests/credentials-oidc.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ea3c0..0ea2648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.0...HEAD) +### Added +- Support for custom OIDC token endpoint paths (#141, #238) +- Compatibility with Zitadel, Entra ID, and other OIDC providers using non-standard token paths + +### Fixed +- OIDC token URL construction to handle custom paths consistently with other SDKs +- Telemetry configuration issues in credentials flow + ## v0.9.0 ### [v0.9.0](https://github.com/openfga/js-sdk/compare/v0.8.1...v0.9.0) (2025-06-04) diff --git a/README.md b/README.md index aa199e4..c68e137 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,212 @@ const fgaClient = new OpenFgaClient({ }); ``` +### OIDC Token Endpoint Configuration + +The SDK supports custom OIDC token endpoints for compatibility with various OIDC providers. + +#### Default Behavior +- When `apiTokenIssuer` is just a domain (e.g., `"auth.example.com"`), the SDK appends `/oauth/token` +- Example: `auth.example.com` → `https://auth.example.com/oauth/token` + +#### Custom Token Paths +- When `apiTokenIssuer` includes a path, that path is used as-is +- Examples: + - `auth.example.com/oauth/v2` → `https://auth.example.com/oauth/v2` + - `https://auth.example.com/oauth/v2/token` → `https://auth.example.com/oauth/v2/token` + +#### Provider Examples + +**Zitadel:** +```javascript +apiTokenIssuer: 'https://auth.zitadel.example/oauth/v2/token' +``` + +Entra ID (Azure AD): +``` +apiTokenIssuer: 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token' +``` + +Auth0: + +``` +apiTokenIssuer: 'https://your-domain.auth0.com/oauth/token' +``` + +This ensures compatibility with OIDC providers that use non-standard token endpoint paths. + +``` +## 3. Create the OIDC Test File + +Create `tests/credentials-oidc.test.ts`: + +```typescript +import nock from 'nock'; +import { OpenFgaClient, UserClientConfigurationParams } from '../src'; +import { CredentialsMethod } from '../src/credentials'; +import { baseConfig } from './helpers/default-config'; + +describe('OIDC Token Path Handling', () => { + beforeEach(() => { + nock.disableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + const testOidcConfig = (apiTokenIssuer: string, expectedTokenUrl: string) => { + const config: UserClientConfigurationParams = { + ...baseConfig, + credentials: { + method: CredentialsMethod.ClientCredentials, + config: { + clientId: 'test-client', + clientSecret: 'test-secret', + apiTokenIssuer, + apiAudience: 'https://api.fga.example' + } + } + }; + + // Mock the token endpoint + const tokenScope = nock(expectedTokenUrl.split('/').slice(0, 3).join('/')) + .post(expectedTokenUrl.split('/').slice(3).join('/')) + .reply(200, { + access_token: 'test-token', + expires_in: 300 + }); + + // Mock the FGA API call + const apiScope = nock('https://api.fga.example') + .post(`/stores/${baseConfig.storeId}/check`) + .reply(200, { allowed: true }); + + return { config, tokenScope, apiScope }; + }; + + it('should append /oauth/token when no path is provided', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com', + 'https://auth.example.com/oauth/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should respect custom token paths', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com/oauth/v2/token', + 'https://auth.example.com/oauth/v2/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should handle full URLs with custom paths', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'https://auth.example.com/oauth/v2/token', + 'https://auth.example.com/oauth/v2/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should handle paths with trailing slashes', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com/oauth/v2/', + 'https://auth.example.com/oauth/v2' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should handle root path correctly', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com/', + 'https://auth.example.com/oauth/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should work with Zitadel-style paths (/oauth/v2/token)', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'https://auth.zitadel.example/oauth/v2/token', + 'https://auth.zitadel.example/oauth/v2/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should work with Entra ID/Azure AD style paths', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token', + 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); +}); +``` + ### Custom Headers #### Default Headers diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 7a7fa96..34711c2 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -10,7 +10,6 @@ * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. */ - import globalAxios, { AxiosInstance } from "axios"; import * as jose from "jose"; @@ -93,9 +92,21 @@ export class Credentials { assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience); assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey); - if (!isWellFormedUriString(`https://${authConfig.config?.apiTokenIssuer}`)) { + //Fixed validation: Handle both full URLs and domain-only issuers + const apiTokenIssuer = authConfig.config?.apiTokenIssuer; + let issuerToValidate: string; + + if (apiTokenIssuer?.startsWith("http://") || apiTokenIssuer?.startsWith("https://")) { + // Already a full URL, use as-is + issuerToValidate = apiTokenIssuer; + } else { + // Domain only, prepend https:// for validation + issuerToValidate = `https://${apiTokenIssuer}`; + } + + if (!isWellFormedUriString(issuerToValidate)) { throw new FgaValidationError( - `Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`); + `Configuration.apiTokenIssuer does not form a valid URI (${issuerToValidate})`); } break; } @@ -143,21 +154,47 @@ export class Credentials { * @return string */ private async refreshAccessToken() { - const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config; - const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`; + const clientCredentials = (this.authConfig as { + method: CredentialsMethod.ClientCredentials; + config: ClientCredentialsConfig; + })?.config; + + // Normalize the token URL - FIXED VERSION + const issuerWithScheme = + clientCredentials.apiTokenIssuer.startsWith("http://") || + clientCredentials.apiTokenIssuer.startsWith("https://") + ? clientCredentials.apiTokenIssuer + : `https://${clientCredentials.apiTokenIssuer}`; + + let tokenUrl: string; + try { + const parsed = new URL(issuerWithScheme); + + // If no path (or just '/'), append the default /oauth/token + if (!parsed.pathname || parsed.pathname === "/") { + tokenUrl = `${parsed.origin}/oauth/token`; + } else { + // Otherwise, respect the existing path (e.g. /oauth/v2/token) + tokenUrl = parsed.toString().replace(/\/$/, ""); // remove trailing slash if any + } + } catch { + // Fallback to previous behavior if parsing fails + tokenUrl = `https://${clientCredentials.apiTokenIssuer}/oauth/token`; + } + const credentialsPayload = await this.buildClientAuthenticationPayload(); - + try { - const wrappedResponse = await attemptHttpRequest({ - url, + const wrappedResponse = await attemptHttpRequest< + ClientSecretRequest | ClientAssertionRequest, + { access_token: string; expires_in: number } + >({ + url: tokenUrl, method: "POST", data: credentialsPayload, headers: { - "Content-Type": "application/x-www-form-urlencoded" - } + "Content-Type": "application/x-www-form-urlencoded", + }, }, { maxRetry: 3, minWaitInMs: 100, @@ -170,13 +207,12 @@ export class Credentials { } if (this.telemetryConfig?.metrics?.counterCredentialsRequest?.attributes) { - let attributes = {}; attributes = TelemetryAttributes.fromRequest({ userAgent: this.baseOptions?.headers["User-Agent"], fgaMethod: "TokenExchange", - url, + url: tokenUrl, // FIXED: Use tokenUrl instead of undefined 'url' resendCount: wrappedResponse?.retries, httpMethod: "POST", credentials: clientCredentials, @@ -216,13 +252,19 @@ export class Credentials { if ((config as PrivateKeyJWTConfig).clientAssertionSigningKey) { const alg = (config as PrivateKeyJWTConfig).clientAssertionSigningAlgorithm || "RS256"; const privateKey = await jose.importPKCS8((config as PrivateKeyJWTConfig).clientAssertionSigningKey, alg); + + //Fixed audience: Handle both full URLs and domain-only issuers + const audienceIssuer = config.apiTokenIssuer.startsWith("http://") || config.apiTokenIssuer.startsWith("https://") + ? config.apiTokenIssuer + : `https://${config.apiTokenIssuer}`; + const assertion = await new jose.SignJWT({}) .setProtectedHeader({ alg }) .setIssuedAt() .setSubject(config.clientId) .setJti(randomUUID()) .setIssuer(config.clientId) - .setAudience(`https://${config.apiTokenIssuer}/`) + .setAudience(`${audienceIssuer}/`) .setExpirationTime("2m") .sign(privateKey); return { @@ -245,4 +287,4 @@ export class Credentials { throw new FgaValidationError("Credentials method is set to ClientCredentials, but no clientSecret or clientAssertionSigningKey is provided"); } -} +} \ No newline at end of file diff --git a/tests/credentials-oidc.test.ts b/tests/credentials-oidc.test.ts new file mode 100644 index 0000000..a050553 --- /dev/null +++ b/tests/credentials-oidc.test.ts @@ -0,0 +1,169 @@ +import * as nock from 'nock'; +import { OpenFgaClient, UserClientConfigurationParams } from '..'; +import { CredentialsMethod } from '../credentials'; +import { baseConfig } from './helpers/default-config'; + +describe('OIDC Token Path Handling', () => { + beforeEach(() => { + nock.disableNetConnect(); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + const testOidcConfig = (apiTokenIssuer: string, expectedTokenUrl: string) => { + const config: UserClientConfigurationParams = { + ...baseConfig, + credentials: { + method: CredentialsMethod.ClientCredentials, + config: { + clientId: 'test-client', + clientSecret: 'test-secret', + apiTokenIssuer, + apiAudience: 'https://api.fga.example' + } + } + }; + + // Parse the expected URL to get base URL and path + const parsedUrl = new URL(expectedTokenUrl); + const baseUrl = `${parsedUrl.protocol}//${parsedUrl.host}`; + const path = parsedUrl.pathname; + + // Mock the token endpoint + const tokenScope = nock(baseUrl) + .post(path) + .reply(200, { + access_token: 'test-token', + expires_in: 300 + }); + + // Mock the FGA API call + const apiScope = nock('https://api.fga.example') + .post(`/stores/${baseConfig.storeId}/check`) + .reply(200, { allowed: true }); + + return { config, tokenScope, apiScope }; + }; + + it('should append /oauth/token when no path is provided', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com', + 'https://auth.example.com/oauth/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should respect custom token paths', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com/oauth/v2/token', + 'https://auth.example.com/oauth/v2/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should handle full URLs with custom paths', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'https://auth.example.com/oauth/v2/token', + 'https://auth.example.com/oauth/v2/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should handle paths with trailing slashes', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com/oauth/v2/', + 'https://auth.example.com/oauth/v2' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should handle root path correctly', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'auth.example.com/', + 'https://auth.example.com/oauth/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should work with Zitadel-style paths (/oauth/v2/token)', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'https://auth.zitadel.example/oauth/v2/token', + 'https://auth.zitadel.example/oauth/v2/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); + + it('should work with Entra ID/Azure AD style paths', async () => { + const { config, tokenScope, apiScope } = testOidcConfig( + 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token', + 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token' + ); + + const client = new OpenFgaClient(config); + await client.check({ + user: 'user:test', + relation: 'reader', + object: 'document:test' + }); + + expect(tokenScope.isDone()).toBe(true); + expect(apiScope.isDone()).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/headers.test.ts b/tests/headers.test.ts index 035c1c5..620fb64 100644 --- a/tests/headers.test.ts +++ b/tests/headers.test.ts @@ -43,7 +43,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Verify all default headers are present @@ -73,7 +73,7 @@ describe("Header Functionality Tests", () => { }); // Test check endpoint - const checkScope = nock(testConfig.apiUrl!) + const checkScope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { expect(this.req.headers["x-persistent-header"]).toBe("should-appear-everywhere"); @@ -81,7 +81,7 @@ describe("Header Functionality Tests", () => { }); // Test read endpoint - const readScope = nock(testConfig.apiUrl!) + const readScope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/read`) .reply(function() { expect(this.req.headers["x-persistent-header"]).toBe("should-appear-everywhere"); @@ -105,7 +105,7 @@ describe("Header Functionality Tests", () => { it("should send per-request headers when specified", async () => { const fgaClient = new OpenFgaClient(testConfig); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { expect(this.req.headers["x-request-header"]).toBe("request-value"); @@ -131,7 +131,7 @@ describe("Header Functionality Tests", () => { const fgaClient = new OpenFgaClient(testConfig); // First request with headers - const firstScope = nock(testConfig.apiUrl!) + const firstScope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { expect(this.req.headers["x-first-request"]).toBe("first-value"); @@ -140,7 +140,7 @@ describe("Header Functionality Tests", () => { }); // Second request with different headers - const secondScope = nock(testConfig.apiUrl!) + const secondScope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { expect(this.req.headers["x-second-request"]).toBe("second-value"); @@ -185,7 +185,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Verify default headers are present @@ -225,7 +225,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { const headers = this.req.headers; @@ -274,7 +274,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Per-request headers should override default headers @@ -312,7 +312,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Non-overridden defaults should remain @@ -348,7 +348,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // HTTP headers are case-insensitive, so request header should override default @@ -389,7 +389,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { const headers = this.req.headers; @@ -417,7 +417,7 @@ describe("Header Functionality Tests", () => { const fgaClient = new OpenFgaClient(testConfig); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Per-request headers override SDK headers (including Content-Type) @@ -454,7 +454,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { const headers = this.req.headers; @@ -493,7 +493,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { const headers = this.req.headers; @@ -529,7 +529,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Should still have SDK headers @@ -554,7 +554,7 @@ describe("Header Functionality Tests", () => { // No baseOptions specified }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Should still have SDK headers @@ -583,7 +583,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { // Default headers should still be present @@ -616,7 +616,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { const headers = this.req.headers; @@ -659,7 +659,7 @@ describe("Header Functionality Tests", () => { } }); - const scope = nock(testConfig.apiUrl!) + const scope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { const headers = this.req.headers; @@ -701,21 +701,21 @@ describe("Header Functionality Tests", () => { }); // Test multiple endpoints - const checkScope = nock(testConfig.apiUrl!) + const checkScope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/check`) .reply(function() { expect(this.req.headers["x-consistent-header"]).toBe("always-present"); return [200, { allowed: true }]; }); - const readScope = nock(testConfig.apiUrl!) + const readScope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/read`) .reply(function() { expect(this.req.headers["x-consistent-header"]).toBe("always-present"); return [200, { tuples: [] }]; }); - const writeScope = nock(testConfig.apiUrl!) + const writeScope = nock('https://api.fga.example') .post(`/stores/${testConfig.storeId}/write`) .reply(function() { expect(this.req.headers["x-consistent-header"]).toBe("always-present"); @@ -743,4 +743,4 @@ describe("Header Functionality Tests", () => { expect(writeScope.isDone()).toBe(true); }); }); -}); +}); \ No newline at end of file From c0696822b221743e425716918200f8126877f14e Mon Sep 17 00:00:00 2001 From: shaibuuneks Date: Fri, 17 Oct 2025 19:50:10 +0000 Subject: [PATCH 3/6] feat: support custom OIDC token endpoint paths for Zitadel, Entra ID, and other providers - Normalize OIDC token URL construction to handle custom paths - Respect existing paths when provided (e.g., /oauth/v2/token) - Only append /oauth/token when no path is specified - Add proper URL parsing and validation for both domain-only and full URL issuers - Fix telemetry configuration issues - Fix client assertion audience for full URL issuers - Add comprehensive tests covering Zitadel, Entra ID, and custom path scenarios - Update README with OIDC configuration examples - Update CHANGELOG with new feature Fixes #141 References #238 --- credentials/credentials.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 34711c2..391f05f 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -96,6 +96,7 @@ export class Credentials { const apiTokenIssuer = authConfig.config?.apiTokenIssuer; let issuerToValidate: string; + if (apiTokenIssuer?.startsWith("http://") || apiTokenIssuer?.startsWith("https://")) { // Already a full URL, use as-is issuerToValidate = apiTokenIssuer; From bd647cc168143d5f9aa61a09356d6430601151ba Mon Sep 17 00:00:00 2001 From: Wisdom Shaibu Date: Tue, 21 Oct 2025 23:35:52 +0100 Subject: [PATCH 4/6] Revise CHANGELOG for new OIDC support and fixes Updated the changelog to reflect new features and changes. --- CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea2648..9450703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,12 @@ # Changelog -## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.0...HEAD) +## [Unreleased](https://github.com/openfga/sdk-generator#238) ### Added - Support for custom OIDC token endpoint paths (#141, #238) - Compatibility with Zitadel, Entra ID, and other OIDC providers using non-standard token paths -### Fixed -- OIDC token URL construction to handle custom paths consistently with other SDKs -- Telemetry configuration issues in credentials flow - ## v0.9.0 ### [v0.9.0](https://github.com/openfga/js-sdk/compare/v0.8.1...v0.9.0) (2025-06-04) From 4004b97320b15825e2f3a55743dd2475a91bb33f Mon Sep 17 00:00:00 2001 From: Wisdom Shaibu Date: Tue, 21 Oct 2025 23:42:17 +0100 Subject: [PATCH 5/6] Remove OIDC Token Endpoint Configuration details Removed OIDC Token Endpoint Configuration section and related details from README. --- README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/README.md b/README.md index c68e137..01cdca2 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,6 @@ const fgaClient = new OpenFgaClient({ }); ``` -### OIDC Token Endpoint Configuration - -The SDK supports custom OIDC token endpoints for compatibility with various OIDC providers. - #### Default Behavior - When `apiTokenIssuer` is just a domain (e.g., `"auth.example.com"`), the SDK appends `/oauth/token` - Example: `auth.example.com` → `https://auth.example.com/oauth/token` @@ -175,12 +171,6 @@ Auth0: ``` apiTokenIssuer: 'https://your-domain.auth0.com/oauth/token' ``` - -This ensures compatibility with OIDC providers that use non-standard token endpoint paths. - -``` -## 3. Create the OIDC Test File - Create `tests/credentials-oidc.test.ts`: ```typescript From d735172398f1744504b79d7c8ccedf0a15f77dbf Mon Sep 17 00:00:00 2001 From: Wisdom Shaibu Date: Tue, 21 Oct 2025 23:49:15 +0100 Subject: [PATCH 6/6] Refactor API token issuer normalization logic Refactored API token issuer normalization into a reusable function for improved code clarity and maintainability. --- credentials/credentials.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 391f05f..ec88d78 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -72,6 +72,17 @@ export class Credentials { } } + /** + * Normalize API token issuer URL by ensuring it has a scheme + * @private + */ + private normalizeApiTokenIssuer(apiTokenIssuer: string): string { + if (apiTokenIssuer.startsWith("http://") || apiTokenIssuer.startsWith("https://")) { + return apiTokenIssuer; + } + return `https://${apiTokenIssuer}`; + } + /** * * @throws {FgaValidationError} @@ -92,11 +103,10 @@ export class Credentials { assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience); assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey); - //Fixed validation: Handle both full URLs and domain-only issuers + // Fixed validation: Handle both full URLs and domain-only issuers const apiTokenIssuer = authConfig.config?.apiTokenIssuer; let issuerToValidate: string; - if (apiTokenIssuer?.startsWith("http://") || apiTokenIssuer?.startsWith("https://")) { // Already a full URL, use as-is issuerToValidate = apiTokenIssuer; @@ -160,12 +170,8 @@ export class Credentials { config: ClientCredentialsConfig; })?.config; - // Normalize the token URL - FIXED VERSION - const issuerWithScheme = - clientCredentials.apiTokenIssuer.startsWith("http://") || - clientCredentials.apiTokenIssuer.startsWith("https://") - ? clientCredentials.apiTokenIssuer - : `https://${clientCredentials.apiTokenIssuer}`; + // Normalize the token URL - using reusable function + const issuerWithScheme = this.normalizeApiTokenIssuer(clientCredentials.apiTokenIssuer); let tokenUrl: string; try { @@ -213,7 +219,7 @@ export class Credentials { attributes = TelemetryAttributes.fromRequest({ userAgent: this.baseOptions?.headers["User-Agent"], fgaMethod: "TokenExchange", - url: tokenUrl, // FIXED: Use tokenUrl instead of undefined 'url' + url: tokenUrl, resendCount: wrappedResponse?.retries, httpMethod: "POST", credentials: clientCredentials, @@ -254,10 +260,8 @@ export class Credentials { const alg = (config as PrivateKeyJWTConfig).clientAssertionSigningAlgorithm || "RS256"; const privateKey = await jose.importPKCS8((config as PrivateKeyJWTConfig).clientAssertionSigningKey, alg); - //Fixed audience: Handle both full URLs and domain-only issuers - const audienceIssuer = config.apiTokenIssuer.startsWith("http://") || config.apiTokenIssuer.startsWith("https://") - ? config.apiTokenIssuer - : `https://${config.apiTokenIssuer}`; + + const audienceIssuer = this.normalizeApiTokenIssuer(config.apiTokenIssuer); const assertion = await new jose.SignJWT({}) .setProtectedHeader({ alg }) @@ -288,4 +292,4 @@ export class Credentials { throw new FgaValidationError("Credentials method is set to ClientCredentials, but no clientSecret or clientAssertionSigningKey is provided"); } -} \ No newline at end of file +}