Skip to content
Open
33 changes: 30 additions & 3 deletions credentials/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ interface ClientAssertionRequest {
audience: string;
}

export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token";

export class Credentials {
private accessToken?: string;
private accessTokenExpiryDate?: Date;
Expand Down Expand Up @@ -93,9 +95,9 @@ 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}`)) {
if (!isWellFormedUriString(this.buildApiTokenUrl(authConfig.config?.apiTokenIssuer))) {
throw new FgaValidationError(
`Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`);
`Configuration.apiTokenIssuer does not form a valid URI (${authConfig.config?.apiTokenIssuer})`);
}
break;
}
Expand Down Expand Up @@ -138,13 +140,38 @@ export class Credentials {
}
}

/**
* Constructs the token endpoint URL from the provided API token issuer.
* Defaults to https:// scheme if none provided and appends the default
* token endpoint path when the issuer has no path or only a root path.
*
* @param apiTokenIssuer
* @return string The constructed token endpoint URL, or empty string if invalid
*/
private buildApiTokenUrl(apiTokenIssuer: string): string {
let url = URL.parse(apiTokenIssuer);
if (!url && !apiTokenIssuer.startsWith("https://") && !apiTokenIssuer.startsWith("http://")) {
url = URL.parse(`https://${apiTokenIssuer}`);
}

if (url) {
if (url.pathname === "" || url.pathname === "/") {
url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;
}

return url.toString();
}

return "";
}

/**
* Request new access token
* @return string
*/
private async refreshAccessToken() {
const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config;
const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`;
const url = this.buildApiTokenUrl(clientCredentials.apiTokenIssuer);
const credentialsPayload = await this.buildClientAuthenticationPayload();

try {
Expand Down
120 changes: 120 additions & 0 deletions tests/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as nock from "nock";
import { Credentials, CredentialsMethod, DEFAULT_TOKEN_ENDPOINT_PATH } from "../credentials";
import { AuthCredentialsConfig } from "../credentials/types";
import { TelemetryConfiguration } from "../telemetry/configuration";
import {
OPENFGA_API_AUDIENCE,
OPENFGA_CLIENT_ID,
OPENFGA_CLIENT_SECRET,
} from "./helpers/default-config";

nock.disableNetConnect();

describe("Credentials", () => {
const mockTelemetryConfig: TelemetryConfiguration = new TelemetryConfiguration({});

describe("Refreshing access token", () => {
interface TestCase {
description: string;
apiTokenIssuer: string;
expectedBaseUrl: string;
expectedPath: string;
queryParams?: Record<string, string>;
}

const testCases: TestCase[] = [
{
description: "should use default scheme and token endpoint path when apiTokenIssuer has no scheme and no path",
apiTokenIssuer: "issuer.fga.example",
expectedBaseUrl: "https://issuer.fga.example",
expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`,
},
{
description: "should use default token endpoint path when apiTokenIssuer has root path and no scheme",
apiTokenIssuer: "https://issuer.fga.example/",
expectedBaseUrl: "https://issuer.fga.example",
expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`,
},
{
description: "should preserve custom token endpoint path when provided",
apiTokenIssuer: "https://issuer.fga.example/some_endpoint",
expectedBaseUrl: "https://issuer.fga.example",
expectedPath: "/some_endpoint",
},
{
description: "should preserve custom token endpoint path with nested path when provided",
apiTokenIssuer: "https://issuer.fga.example/api/v1/oauth/token",
expectedBaseUrl: "https://issuer.fga.example",
expectedPath: "/api/v1/oauth/token",
},
{
description: "should add https:// prefix when apiTokenIssuer has no scheme",
apiTokenIssuer: "issuer.fga.example/some_endpoint",
expectedBaseUrl: "https://issuer.fga.example",
expectedPath: "/some_endpoint",
},
{
description: "should preserve http:// scheme when provided",
apiTokenIssuer: "http://issuer.fga.example/some_endpoint",
expectedBaseUrl: "http://issuer.fga.example",
expectedPath: "/some_endpoint",
},
{
description: "should use default path when apiTokenIssuer has https:// scheme but no path",
apiTokenIssuer: "https://issuer.fga.example",
expectedBaseUrl: "https://issuer.fga.example",
expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`,
},
{
description: "should preserve custom path with query parameters",
apiTokenIssuer: "https://issuer.fga.example/some_endpoint?param=value",
expectedBaseUrl: "https://issuer.fga.example",
expectedPath: "/some_endpoint",
queryParams: { param: "value" },
},
{
description: "should preserve custom path with port number",
apiTokenIssuer: "https://issuer.fga.example:8080/some_endpoint",
expectedBaseUrl: "https://issuer.fga.example:8080",
expectedPath: "/some_endpoint",
},
];

test.each(testCases)("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => {
const scope = queryParams
? nock(expectedBaseUrl)
.post(expectedPath)
.query(queryParams)
.reply(200, {
access_token: "test-token",
expires_in: 300,
})
: nock(expectedBaseUrl)
.post(expectedPath)
.reply(200, {
access_token: "test-token",
expires_in: 300,
});

const credentials = new Credentials(
{
method: CredentialsMethod.ClientCredentials,
config: {
apiTokenIssuer,
apiAudience: OPENFGA_API_AUDIENCE,
clientId: OPENFGA_CLIENT_ID,
clientSecret: OPENFGA_CLIENT_SECRET,
},
} as AuthCredentialsConfig,
undefined,
mockTelemetryConfig,
);

await credentials.getAccessTokenHeader();

expect(scope.isDone()).toBe(true);
nock.cleanAll();
});
});
});

19 changes: 17 additions & 2 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,30 @@ describe("OpenFGA SDK", function () {
).not.toThrowError();
});

it("should validate apiTokenIssuer in configuration (should not allow scheme as part of the apiTokenIssuer)", () => {
it.each(["https://", "http://", ""])("should allow valid schemes as part of the apiTokenIssuer in configuration (%s)", (scheme) => {
expect(
() => new OpenFgaApi({
...baseConfig,
credentials: {
method: CredentialsMethod.ClientCredentials,
config: {
...(baseConfig.credentials as any).config,
apiTokenIssuer: "https://tokenissuer.fga.example"
apiTokenIssuer: `${scheme}tokenissuer.fga.example`
}
} as Configuration["credentials"]
})
).not.toThrowError();
});

it.each(["tcp://", "grpc://", "file://"])("should not allow invalid schemes as part of the apiTokenIssuer in configuration (%s)", (scheme) => {
expect(
() => new OpenFgaApi({
...baseConfig,
credentials: {
method: CredentialsMethod.ClientCredentials,
config: {
...(baseConfig.credentials as any).config,
apiTokenIssuer: `${scheme}tokenissuer.fga.example`
}
} as Configuration["credentials"]
})
Expand Down