@@ -144,6 +144,212 @@ const fgaClient = new OpenFgaClient({
144144});
145145```
146146
147+ ### OIDC Token Endpoint Configuration
148+
149+ The SDK supports custom OIDC token endpoints for compatibility with various OIDC providers.
150+
151+ #### Default Behavior
152+ - When ` apiTokenIssuer ` is just a domain (e.g., ` "auth.example.com" ` ), the SDK appends ` /oauth/token `
153+ - Example: ` auth.example.com ` → ` https://auth.example.com/oauth/token `
154+
155+ #### Custom Token Paths
156+ - When ` apiTokenIssuer ` includes a path, that path is used as-is
157+ - Examples:
158+ - ` auth.example.com/oauth/v2 ` → ` https://auth.example.com/oauth/v2 `
159+ - ` https://auth.example.com/oauth/v2/token ` → ` https://auth.example.com/oauth/v2/token `
160+
161+ #### Provider Examples
162+
163+ ** Zitadel:**
164+ ``` javascript
165+ apiTokenIssuer: ' https://auth.zitadel.example/oauth/v2/token'
166+ ```
167+
168+ Entra ID (Azure AD):
169+ ```
170+ apiTokenIssuer: 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token'
171+ ```
172+
173+ Auth0:
174+
175+ ```
176+ apiTokenIssuer: 'https://your-domain.auth0.com/oauth/token'
177+ ```
178+
179+ This ensures compatibility with OIDC providers that use non-standard token endpoint paths.
180+
181+ ```
182+ ## 3. Create the OIDC Test File
183+
184+ Create `tests/credentials-oidc.test.ts`:
185+
186+ ```typescript
187+ import nock from 'nock';
188+ import { OpenFgaClient, UserClientConfigurationParams } from '../src';
189+ import { CredentialsMethod } from '../src/credentials';
190+ import { baseConfig } from './helpers/default-config';
191+
192+ describe('OIDC Token Path Handling', () => {
193+ beforeEach(() => {
194+ nock.disableNetConnect();
195+ });
196+
197+ afterEach(() => {
198+ nock.cleanAll();
199+ nock.enableNetConnect();
200+ });
201+
202+ const testOidcConfig = (apiTokenIssuer: string, expectedTokenUrl: string) => {
203+ const config: UserClientConfigurationParams = {
204+ ...baseConfig,
205+ credentials: {
206+ method: CredentialsMethod.ClientCredentials,
207+ config: {
208+ clientId: 'test-client',
209+ clientSecret: 'test-secret',
210+ apiTokenIssuer,
211+ apiAudience: 'https://api.fga.example'
212+ }
213+ }
214+ };
215+
216+ // Mock the token endpoint
217+ const tokenScope = nock(expectedTokenUrl.split('/').slice(0, 3).join('/'))
218+ .post(expectedTokenUrl.split('/').slice(3).join('/'))
219+ .reply(200, {
220+ access_token: 'test-token',
221+ expires_in: 300
222+ });
223+
224+ // Mock the FGA API call
225+ const apiScope = nock('https://api.fga.example')
226+ .post(`/stores/${baseConfig.storeId}/check`)
227+ .reply(200, { allowed: true });
228+
229+ return { config, tokenScope, apiScope };
230+ };
231+
232+ it('should append /oauth/token when no path is provided', async () => {
233+ const { config, tokenScope, apiScope } = testOidcConfig(
234+ 'auth.example.com',
235+ 'https://auth.example.com/oauth/token'
236+ );
237+
238+ const client = new OpenFgaClient(config);
239+ await client.check({
240+ user: 'user:test',
241+ relation: 'reader',
242+ object: 'document:test'
243+ });
244+
245+ expect(tokenScope.isDone()).toBe(true);
246+ expect(apiScope.isDone()).toBe(true);
247+ });
248+
249+ it('should respect custom token paths', async () => {
250+ const { config, tokenScope, apiScope } = testOidcConfig(
251+ 'auth.example.com/oauth/v2/token',
252+ 'https://auth.example.com/oauth/v2/token'
253+ );
254+
255+ const client = new OpenFgaClient(config);
256+ await client.check({
257+ user: 'user:test',
258+ relation: 'reader',
259+ object: 'document:test'
260+ });
261+
262+ expect(tokenScope.isDone()).toBe(true);
263+ expect(apiScope.isDone()).toBe(true);
264+ });
265+
266+ it('should handle full URLs with custom paths', async () => {
267+ const { config, tokenScope, apiScope } = testOidcConfig(
268+ 'https://auth.example.com/oauth/v2/token',
269+ 'https://auth.example.com/oauth/v2/token'
270+ );
271+
272+ const client = new OpenFgaClient(config);
273+ await client.check({
274+ user: 'user:test',
275+ relation: 'reader',
276+ object: 'document:test'
277+ });
278+
279+ expect(tokenScope.isDone()).toBe(true);
280+ expect(apiScope.isDone()).toBe(true);
281+ });
282+
283+ it('should handle paths with trailing slashes', async () => {
284+ const { config, tokenScope, apiScope } = testOidcConfig(
285+ 'auth.example.com/oauth/v2/',
286+ 'https://auth.example.com/oauth/v2'
287+ );
288+
289+ const client = new OpenFgaClient(config);
290+ await client.check({
291+ user: 'user:test',
292+ relation: 'reader',
293+ object: 'document:test'
294+ });
295+
296+ expect(tokenScope.isDone()).toBe(true);
297+ expect(apiScope.isDone()).toBe(true);
298+ });
299+
300+ it('should handle root path correctly', async () => {
301+ const { config, tokenScope, apiScope } = testOidcConfig(
302+ 'auth.example.com/',
303+ 'https://auth.example.com/oauth/token'
304+ );
305+
306+ const client = new OpenFgaClient(config);
307+ await client.check({
308+ user: 'user:test',
309+ relation: 'reader',
310+ object: 'document:test'
311+ });
312+
313+ expect(tokenScope.isDone()).toBe(true);
314+ expect(apiScope.isDone()).toBe(true);
315+ });
316+
317+ it('should work with Zitadel-style paths (/oauth/v2/token)', async () => {
318+ const { config, tokenScope, apiScope } = testOidcConfig(
319+ 'https://auth.zitadel.example/oauth/v2/token',
320+ 'https://auth.zitadel.example/oauth/v2/token'
321+ );
322+
323+ const client = new OpenFgaClient(config);
324+ await client.check({
325+ user: 'user:test',
326+ relation: 'reader',
327+ object: 'document:test'
328+ });
329+
330+ expect(tokenScope.isDone()).toBe(true);
331+ expect(apiScope.isDone()).toBe(true);
332+ });
333+
334+ it('should work with Entra ID/Azure AD style paths', async () => {
335+ const { config, tokenScope, apiScope } = testOidcConfig(
336+ 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token',
337+ 'https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token'
338+ );
339+
340+ const client = new OpenFgaClient(config);
341+ await client.check({
342+ user: 'user:test',
343+ relation: 'reader',
344+ object: 'document:test'
345+ });
346+
347+ expect(tokenScope.isDone()).toBe(true);
348+ expect(apiScope.isDone()).toBe(true);
349+ });
350+ });
351+ ```
352+
147353### Custom Headers
148354
149355#### Default Headers
0 commit comments