diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ea3c0..9450703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ # 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 ## v0.9.0 diff --git a/README.md b/README.md index aa199e4..01cdca2 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,202 @@ const fgaClient = new OpenFgaClient({ }); ``` +#### 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' +``` +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..ec88d78 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"; @@ -73,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} @@ -93,9 +103,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 +165,43 @@ 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 - using reusable function + const issuerWithScheme = this.normalizeApiTokenIssuer(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 +214,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, resendCount: wrappedResponse?.retries, httpMethod: "POST", credentials: clientCredentials, @@ -216,13 +259,17 @@ 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); + + + const audienceIssuer = this.normalizeApiTokenIssuer(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 { 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 73c2d6d..620fb64 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(); }); @@ -41,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 @@ -71,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"); @@ -79,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"); @@ -103,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"); @@ -129,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"); @@ -138,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"); @@ -183,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 @@ -223,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; @@ -272,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 @@ -310,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 @@ -346,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 @@ -387,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; @@ -415,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) @@ -452,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; @@ -491,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; @@ -527,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 @@ -552,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 @@ -581,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 @@ -614,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; @@ -657,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; @@ -699,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"); @@ -741,4 +743,4 @@ describe("Header Functionality Tests", () => { expect(writeScope.isDone()).toBe(true); }); }); -}); +}); \ No newline at end of file