Skip to content
Open
48 changes: 44 additions & 4 deletions credentials/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { TelemetryAttributes } from "../telemetry/attributes";
import { TelemetryCounters } from "../telemetry/counters";
import { TelemetryConfiguration } from "../telemetry/configuration";
import { randomUUID } from "crypto";
import { URL } from "url";

interface ClientSecretRequest {
client_id: string;
Expand All @@ -37,6 +38,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 +96,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.normalizeApiTokenIssuer(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 (${this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer)})`);
}
break;
}
Expand Down Expand Up @@ -138,13 +141,49 @@ export class Credentials {
}
}

/**
* Normalize API token issuer URL by ensuring it has a scheme
* @private
* @param apiTokenIssuer
* @return string The normalized API token issuer URL
*/
private normalizeApiTokenIssuer(apiTokenIssuer: string): string {
if (apiTokenIssuer.startsWith("http://") || apiTokenIssuer.startsWith("https://")) {
return apiTokenIssuer;
}
return `https://${apiTokenIssuer}`;
}

/**
* 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.
* @private
* @param apiTokenIssuer
* @return string The constructed token endpoint URL if valid, otherwise throws an error
* @throws {FgaValidationError} If the API token issuer URL is invalid
*/
private buildApiTokenUrl(apiTokenIssuer: string): string {
const normalizedApiTokenIssuer = this.normalizeApiTokenIssuer(apiTokenIssuer);

try {
const url = new URL(normalizedApiTokenIssuer);
if (url.pathname === "" || url.pathname === "/") {
url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;
}
return url.toString();
} catch {
throw new FgaValidationError(`Invalid API token issuer URL: ${normalizedApiTokenIssuer}`);
}
}

/**
* 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 Expand Up @@ -216,13 +255,14 @@ 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
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