Skip to content

Commit 38392a1

Browse files
committed
feat: support custom OIDC token endpoint URL
1 parent 847b9ff commit 38392a1

File tree

3 files changed

+166
-4
lines changed

3 files changed

+166
-4
lines changed

credentials/credentials.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ interface ClientAssertionRequest {
3737
audience: string;
3838
}
3939

40+
export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token";
41+
4042
export class Credentials {
4143
private accessToken?: string;
4244
private accessTokenExpiryDate?: Date;
@@ -93,7 +95,7 @@ export class Credentials {
9395
assertParamExists("Credentials", "config.apiAudience", authConfig.config?.apiAudience);
9496
assertParamExists("Credentials", "config.clientSecret or config.clientAssertionSigningKey", (authConfig.config as ClientSecretConfig).clientSecret || (authConfig.config as PrivateKeyJWTConfig).clientAssertionSigningKey);
9597

96-
if (!isWellFormedUriString(`https://${authConfig.config?.apiTokenIssuer}`)) {
98+
if (!isWellFormedUriString(this.buildApiTokenUrl(authConfig.config?.apiTokenIssuer))) {
9799
throw new FgaValidationError(
98100
`Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`);
99101
}
@@ -138,13 +140,38 @@ export class Credentials {
138140
}
139141
}
140142

143+
/**
144+
* Constructs the token endpoint URL from the provided API token issuer.
145+
* Defaults to https:// scheme if none provided and appends the default
146+
* token endpoint path when the issuer has no path or only a root path.
147+
*
148+
* @param apiTokenIssuer
149+
* @return string The constructed token endpoint URL, or empty string if invalid
150+
*/
151+
private buildApiTokenUrl(apiTokenIssuer: string): string {
152+
let url = URL.parse(apiTokenIssuer);
153+
if (!url && !apiTokenIssuer.startsWith("https://") && !apiTokenIssuer.startsWith("http://")) {
154+
url = URL.parse(`https://${apiTokenIssuer}`);
155+
}
156+
157+
if (url) {
158+
if (url.pathname === "" || url.pathname === "/") {
159+
url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;
160+
}
161+
162+
return url.toString();
163+
}
164+
165+
return "";
166+
}
167+
141168
/**
142169
* Request new access token
143170
* @return string
144171
*/
145172
private async refreshAccessToken() {
146173
const clientCredentials = (this.authConfig as { method: CredentialsMethod.ClientCredentials; config: ClientCredentialsConfig })?.config;
147-
const url = `https://${clientCredentials.apiTokenIssuer}/oauth/token`;
174+
const url = this.buildApiTokenUrl(clientCredentials.apiTokenIssuer);
148175
const credentialsPayload = await this.buildClientAuthenticationPayload();
149176

150177
try {

tests/credentials.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import * as nock from "nock";
2+
import { Credentials, CredentialsMethod, DEFAULT_TOKEN_ENDPOINT_PATH } from "../credentials";
3+
import { AuthCredentialsConfig } from "../credentials/types";
4+
import { TelemetryConfiguration } from "../telemetry/configuration";
5+
import {
6+
OPENFGA_API_AUDIENCE,
7+
OPENFGA_CLIENT_ID,
8+
OPENFGA_CLIENT_SECRET,
9+
} from "./helpers/default-config";
10+
11+
nock.disableNetConnect();
12+
13+
describe("Credentials", () => {
14+
const mockTelemetryConfig: TelemetryConfiguration = new TelemetryConfiguration({});
15+
16+
describe("Refreshing access token", () => {
17+
interface TestCase {
18+
description: string;
19+
apiTokenIssuer: string;
20+
expectedBaseUrl: string;
21+
expectedPath: string;
22+
queryParams?: Record<string, string>;
23+
}
24+
25+
const testCases: TestCase[] = [
26+
{
27+
description: "should use default scheme and token endpoint path when apiTokenIssuer has no scheme and no path",
28+
apiTokenIssuer: "issuer.fga.example",
29+
expectedBaseUrl: "https://issuer.fga.example",
30+
expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`,
31+
},
32+
{
33+
description: "should use default token endpoint path when apiTokenIssuer has root path and no scheme",
34+
apiTokenIssuer: "https://issuer.fga.example/",
35+
expectedBaseUrl: "https://issuer.fga.example",
36+
expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`,
37+
},
38+
{
39+
description: "should preserve custom token endpoint path when provided",
40+
apiTokenIssuer: "https://issuer.fga.example/some_endpoint",
41+
expectedBaseUrl: "https://issuer.fga.example",
42+
expectedPath: "/some_endpoint",
43+
},
44+
{
45+
description: "should preserve custom token endpoint path with nested path when provided",
46+
apiTokenIssuer: "https://issuer.fga.example/api/v1/oauth/token",
47+
expectedBaseUrl: "https://issuer.fga.example",
48+
expectedPath: "/api/v1/oauth/token",
49+
},
50+
{
51+
description: "should add https:// prefix when apiTokenIssuer has no scheme",
52+
apiTokenIssuer: "issuer.fga.example/some_endpoint",
53+
expectedBaseUrl: "https://issuer.fga.example",
54+
expectedPath: "/some_endpoint",
55+
},
56+
{
57+
description: "should preserve http:// scheme when provided",
58+
apiTokenIssuer: "http://issuer.fga.example/some_endpoint",
59+
expectedBaseUrl: "http://issuer.fga.example",
60+
expectedPath: "/some_endpoint",
61+
},
62+
{
63+
description: "should use default path when apiTokenIssuer has https:// scheme but no path",
64+
apiTokenIssuer: "https://issuer.fga.example",
65+
expectedBaseUrl: "https://issuer.fga.example",
66+
expectedPath: `/${DEFAULT_TOKEN_ENDPOINT_PATH}`,
67+
},
68+
{
69+
description: "should preserve custom path with query parameters",
70+
apiTokenIssuer: "https://issuer.fga.example/some_endpoint?param=value",
71+
expectedBaseUrl: "https://issuer.fga.example",
72+
expectedPath: "/some_endpoint",
73+
queryParams: { param: "value" },
74+
},
75+
{
76+
description: "should preserve custom path with port number",
77+
apiTokenIssuer: "https://issuer.fga.example:8080/some_endpoint",
78+
expectedBaseUrl: "https://issuer.fga.example:8080",
79+
expectedPath: "/some_endpoint",
80+
},
81+
];
82+
83+
test.each(testCases)("$description", async ({ apiTokenIssuer, expectedBaseUrl, expectedPath, queryParams }) => {
84+
const scope = queryParams
85+
? nock(expectedBaseUrl)
86+
.post(expectedPath)
87+
.query(queryParams)
88+
.reply(200, {
89+
access_token: "test-token",
90+
expires_in: 300,
91+
})
92+
: nock(expectedBaseUrl)
93+
.post(expectedPath)
94+
.reply(200, {
95+
access_token: "test-token",
96+
expires_in: 300,
97+
});
98+
99+
const credentials = new Credentials(
100+
{
101+
method: CredentialsMethod.ClientCredentials,
102+
config: {
103+
apiTokenIssuer,
104+
apiAudience: OPENFGA_API_AUDIENCE,
105+
clientId: OPENFGA_CLIENT_ID,
106+
clientSecret: OPENFGA_CLIENT_SECRET,
107+
},
108+
} as AuthCredentialsConfig,
109+
undefined,
110+
mockTelemetryConfig,
111+
);
112+
113+
await credentials.getAccessTokenHeader();
114+
115+
expect(scope.isDone()).toBe(true);
116+
nock.cleanAll();
117+
});
118+
});
119+
});
120+

tests/index.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,30 @@ describe("OpenFGA SDK", function () {
6161
).not.toThrowError();
6262
});
6363

64-
it("should validate apiTokenIssuer in configuration (should not allow scheme as part of the apiTokenIssuer)", () => {
64+
it.each(["https://", "http://", ""])("should allow valid schemes as part of the apiTokenIssuer in configuration (%s)", (scheme) => {
6565
expect(
6666
() => new OpenFgaApi({
6767
...baseConfig,
6868
credentials: {
6969
method: CredentialsMethod.ClientCredentials,
7070
config: {
7171
...(baseConfig.credentials as any).config,
72-
apiTokenIssuer: "https://tokenissuer.fga.example"
72+
apiTokenIssuer: `${scheme}tokenissuer.fga.example`
73+
}
74+
} as Configuration["credentials"]
75+
})
76+
).not.toThrowError();
77+
});
78+
79+
it.each(["tcp://", "grpc://", "file://"])("should not allow invalid schemes as part of the apiTokenIssuer in configuration (%s)", (scheme) => {
80+
expect(
81+
() => new OpenFgaApi({
82+
...baseConfig,
83+
credentials: {
84+
method: CredentialsMethod.ClientCredentials,
85+
config: {
86+
...(baseConfig.credentials as any).config,
87+
apiTokenIssuer: `${scheme}tokenissuer.fga.example`
7388
}
7489
} as Configuration["credentials"]
7590
})

0 commit comments

Comments
 (0)