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
53 changes: 48 additions & 5 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,11 @@ interface ClientAssertionRequest {
audience: string;
}

const HTTP_SCHEME = "http://";
const HTTPS_SCHEME = "https://";

export const DEFAULT_TOKEN_ENDPOINT_PATH = "oauth/token";

export class Credentials {
private accessToken?: string;
private accessTokenExpiryDate?: Date;
Expand Down Expand Up @@ -87,16 +93,18 @@ export class Credentials {
assertParamExists("Credentials", "config.headerName", authConfig.config?.headerName);
assertParamExists("Credentials", "config.headerName", authConfig.config?.headerName);
break;
case CredentialsMethod.ClientCredentials:
case CredentialsMethod.ClientCredentials: {
assertParamExists("Credentials", "config.clientId", authConfig.config?.clientId);
assertParamExists("Credentials", "config.apiTokenIssuer", authConfig.config?.apiTokenIssuer);
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}`)) {
const normalizedApiTokenIssuer = this.normalizeApiTokenIssuer(authConfig.config?.apiTokenIssuer);
if (!isWellFormedUriString(normalizedApiTokenIssuer)) {
throw new FgaValidationError(
`Configuration.apiTokenIssuer does not form a valid URI (https://${authConfig.config?.apiTokenIssuer})`);
`Configuration.apiTokenIssuer does not form a valid URI (${normalizedApiTokenIssuer})`);
}
}
break;
}
}
Expand Down Expand Up @@ -138,13 +146,47 @@ 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_SCHEME) || apiTokenIssuer.startsWith(HTTPS_SCHEME)) {
return apiTokenIssuer;
}
return `${HTTPS_SCHEME}${apiTokenIssuer}`;
}

/**
* Constructs the token endpoint URL from the provided API token issuer.
* @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.match(/^\/+$/)) {
url.pathname = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;
}
return url.toString(); // Query params are preserved in the URL
} 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 +258,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}/`) // Trailing slash is required by the OAuth 2.0 specification
.setExpirationTime("2m")
.sign(privateKey);
return {
Expand Down
Loading
Loading