Skip to content

Commit e98670f

Browse files
committed
feat: support custom OIDC token endpoint paths
1 parent 0077d28 commit e98670f

File tree

5 files changed

+441
-38
lines changed

5 files changed

+441
-38
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.0...HEAD)
55

6+
### Added
7+
- Support for custom OIDC token endpoint paths (#141, #238)
8+
- Compatibility with Zitadel, Entra ID, and other OIDC providers using non-standard token paths
9+
10+
### Fixed
11+
- OIDC token URL construction to handle custom paths consistently with other SDKs
12+
- Telemetry configuration issues in credentials flow
13+
614
## v0.9.0
715

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

README.md

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

credentials/credentials.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
* NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT.
1111
*/
1212

13-
1413
import globalAxios, { AxiosInstance } from "axios";
1514
import * as jose from "jose";
1615

@@ -143,21 +142,47 @@ export class Credentials {
143142
* @return string
144143
*/
145144
private async refreshAccessToken() {
146-
const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config;
147-
const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`;
145+
const clientCredentials = (this.authConfig as {
146+
method: CredentialsMethod.ClientCredentials;
147+
config: ClientCredentialsConfig;
148+
})?.config;
149+
150+
//Normalize the token URL - FIXED VERSION
151+
const issuerWithScheme =
152+
clientCredentials.apiTokenIssuer.startsWith("http://") ||
153+
clientCredentials.apiTokenIssuer.startsWith("https://")
154+
? clientCredentials.apiTokenIssuer
155+
: `https://${clientCredentials.apiTokenIssuer}`;
156+
157+
let tokenUrl: string;
158+
try {
159+
const parsed = new URL(issuerWithScheme);
160+
161+
// If no path (or just '/'), append the default /oauth/token
162+
if (!parsed.pathname || parsed.pathname === "/") {
163+
tokenUrl = `${parsed.origin}/oauth/token`;
164+
} else {
165+
// Otherwise, respect the existing path (e.g. /oauth/v2/token)
166+
tokenUrl = parsed.toString().replace(/\/$/, ""); // remove trailing slash if any
167+
}
168+
} catch {
169+
// Fallback to previous behavior if parsing fails
170+
tokenUrl = `https://${clientCredentials.apiTokenIssuer}/oauth/token`;
171+
}
172+
148173
const credentialsPayload = await this.buildClientAuthenticationPayload();
149-
174+
150175
try {
151-
const wrappedResponse = await attemptHttpRequest<ClientSecretRequest|ClientAssertionRequest, {
152-
access_token: string,
153-
expires_in: number,
154-
}>({
155-
url,
176+
const wrappedResponse = await attemptHttpRequest<
177+
ClientSecretRequest | ClientAssertionRequest,
178+
{ access_token: string; expires_in: number }
179+
>({
180+
url: tokenUrl,
156181
method: "POST",
157182
data: credentialsPayload,
158183
headers: {
159-
"Content-Type": "application/x-www-form-urlencoded"
160-
}
184+
"Content-Type": "application/x-www-form-urlencoded",
185+
},
161186
}, {
162187
maxRetry: 3,
163188
minWaitInMs: 100,
@@ -170,13 +195,12 @@ export class Credentials {
170195
}
171196

172197
if (this.telemetryConfig?.metrics?.counterCredentialsRequest?.attributes) {
173-
174198
let attributes = {};
175199

176200
attributes = TelemetryAttributes.fromRequest({
177201
userAgent: this.baseOptions?.headers["User-Agent"],
178202
fgaMethod: "TokenExchange",
179-
url,
203+
url: tokenUrl, // FIXED: Use tokenUrl instead of undefined 'url'
180204
resendCount: wrappedResponse?.retries,
181205
httpMethod: "POST",
182206
credentials: clientCredentials,
@@ -245,4 +269,4 @@ export class Credentials {
245269

246270
throw new FgaValidationError("Credentials method is set to ClientCredentials, but no clientSecret or clientAssertionSigningKey is provided");
247271
}
248-
}
272+
}

0 commit comments

Comments
 (0)