Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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