Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be changed, it would now link out to an issue in the generator repo. My comment below about referencing #238 from the generator repo was intended for that line, not here.


### Added
- Support for custom OIDC token endpoint paths (#141, #238)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you meant to reference openfga/sdk-generator#238?

- Compatibility with Zitadel, Entra ID, and other OIDC providers using non-standard token paths

## v0.9.0

Expand Down
196 changes: 196 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,202 @@ const fgaClient = new OpenFgaClient({
});
```

#### Default Behavior
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The information here is very specific to the token endpoint handling for the client credentials grant. Having a separate section would be a bit confusing it should be info within the client credentials section.

- 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to have specific vendor examples, general behavior documentation will be good. Also a full test file is not necessary just the token domain/path handling will suffice.


**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
Expand Down
79 changes: 63 additions & 16 deletions credentials/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<ClientSecretRequest|ClientAssertionRequest, {
access_token: string,
expires_in: number,
}>({
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading