Skip to content
19 changes: 14 additions & 5 deletions credentials/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,12 +253,21 @@ export class Credentials {

return this.accessToken;
} catch (err: unknown) {
const authErrorContext = {
clientId: clientCredentials.clientId,
audience: clientCredentials.apiAudience,
grantType: CredentialsMethod.ClientCredentials,
};

if (err instanceof FgaApiAuthenticationError) {
err.clientId = err.clientId ?? authErrorContext.clientId;
err.audience = err.audience ?? authErrorContext.audience;
err.grantType = err.grantType ?? authErrorContext.grantType;
throw err;
}

if (err instanceof FgaApiError) {
(err as any).constructor = FgaApiAuthenticationError;
(err as any).name = "FgaApiAuthenticationError";
(err as any).clientId = clientCredentials.clientId;
(err as any).audience = clientCredentials.apiAudience;
(err as any).grantType = "client_credentials";
throw new FgaApiAuthenticationError(err, authErrorContext);
}

throw err;
Comment thread
rhamzeh marked this conversation as resolved.
Expand Down
78 changes: 67 additions & 11 deletions errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ function getResponseHeaders(err: AxiosError): any {
: {};
}

function parseRequestData(data: unknown): any {
if (typeof data !== "string") {
return data;
}

try {
return JSON.parse(data);
} catch {
try {
const params = new URLSearchParams(data);
const parsedParams = Object.fromEntries(params.entries());
return Object.keys(parsedParams).length > 0 ? parsedParams : undefined;
} catch {
return undefined;
}
}
}

function getAuthenticationErrorMessage(err: AxiosError | FgaApiError): string {
const statusText = err instanceof FgaApiError ? err.statusText : err.response?.statusText;
return `FGA Authentication Error.${statusText ? ` ${statusText}` : ""}`;
}

/**
*
* @export
Expand Down Expand Up @@ -201,20 +224,53 @@ export class FgaApiAuthenticationError extends FgaApiError {
public grantType?: string;
public apiErrorCode?: string;

constructor(err: AxiosError) {
super(err);
this.message = `FGA Authentication Error.${err.response?.statusText ? ` ${err.response.statusText}` : ""}`;
constructor(err: AxiosError | FgaApiError, context?: {
clientId?: string;
audience?: string;
grantType?: string;
}) {
super(err as AxiosError, getAuthenticationErrorMessage(err));

if (err instanceof FgaApiError) {
this.statusCode = err.statusCode;
this.statusText = err.statusText;
this.requestURL = err.requestURL;
this.method = err.method as Method;
this.responseData = err.responseData;
this.responseHeader = err.responseHeader;
this.requestId = err.requestId;
this.apiErrorCode = (err.responseData as any)?.code;

const data: any = parseRequestData(err.requestData);

this.clientId = context?.clientId ?? data?.client_id;
this.audience = context?.audience ?? data?.audience;
this.grantType = context?.grantType ?? data?.grant_type;

if ((err as Error)?.stack) {
this.stack = (err as Error).stack;
}
return;
}

this.statusCode = err.response?.status;
this.statusText = err.response?.statusText;
this.requestURL = err.config?.url;
this.method = err.config?.method as Method;
this.responseData = err.response?.data;
this.responseHeader = err.response?.headers;
this.apiErrorCode = (err.response?.data as any)?.code;

let data: any;
try {
data = JSON.parse(err.config?.data || "{}");
} catch (err) {
/* do nothing */
const errResponseHeaders = getResponseHeaders(err);
this.requestId = errResponseHeaders[cFGARequestId];

const data: any = parseRequestData(err.config?.data);
this.clientId = context?.clientId ?? data?.client_id;
this.audience = context?.audience ?? data?.audience;
this.grantType = context?.grantType ?? data?.grant_type;
if ((err as Error)?.stack) {
this.stack = (err as Error).stack;
}
this.clientId = data?.client_id;
this.audience = data?.audience;
this.grantType = data?.grant_type;
}
}

Expand Down
96 changes: 95 additions & 1 deletion tests/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
OPENFGA_CLIENT_ID,
OPENFGA_CLIENT_SECRET,
} from "./helpers/default-config";
import {FgaValidationError} from "../errors";
import { FgaApiAuthenticationError, FgaValidationError } from "../errors";

describe("Credentials", () => {
const mockTelemetryConfig: TelemetryConfiguration = new TelemetryConfiguration({});
Expand Down Expand Up @@ -539,6 +539,100 @@ describe("Credentials", () => {
expect(scope.isDone()).toBe(true);
});

test("should throw a real FgaApiAuthenticationError instance when token refresh fails", async () => {
const apiTokenIssuer = "issuer.fga.example";
const expectedBaseUrl = "https://issuer.fga.example";
const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;
// We do this to skip the wait time between retries
const setTimeoutSpy = jest.spyOn(global, "setTimeout").mockImplementation(((callback: () => void) => {
callback();
return {} as NodeJS.Timeout;
}) as typeof setTimeout);

const scope = nock(expectedBaseUrl)
.post(expectedPath)
.times(4)
.reply(500, {
code: "internal_error",
message: "token exchange failed",
});
Comment thread
aaguiarz marked this conversation as resolved.

try {
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,
);

let error: unknown;
try {
await credentials.getAccessTokenHeader();
} catch (err) {
error = err;
}

expect(error).toBeInstanceOf(FgaApiAuthenticationError);
const authenticationError = error as FgaApiAuthenticationError;
expect(authenticationError.statusCode).toBe(500);
expect(authenticationError.clientId).toBe(OPENFGA_CLIENT_ID);
expect(authenticationError.audience).toBe(OPENFGA_API_AUDIENCE);
expect(authenticationError.grantType).toBe(CredentialsMethod.ClientCredentials);
expect(scope.isDone()).toBe(true);
} finally {
setTimeoutSpy.mockRestore();
}
});

test("should preserve auth context when token endpoint returns 401", async () => {
const apiTokenIssuer = "issuer.fga.example";
const expectedBaseUrl = "https://issuer.fga.example";
const expectedPath = `/${DEFAULT_TOKEN_ENDPOINT_PATH}`;

const scope = nock(expectedBaseUrl)
.post(expectedPath)
.reply(401, {
code: "unauthorized",
message: "invalid client credentials",
});

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,
);

let error: unknown;
try {
await credentials.getAccessTokenHeader();
} catch (err) {
error = err;
}

expect(error).toBeInstanceOf(FgaApiAuthenticationError);
const authenticationError = error as FgaApiAuthenticationError;
expect(authenticationError.statusCode).toBe(401);
expect(authenticationError.clientId).toBe(OPENFGA_CLIENT_ID);
expect(authenticationError.audience).toBe(OPENFGA_API_AUDIENCE);
expect(authenticationError.grantType).toBe(CredentialsMethod.ClientCredentials);
expect(scope.isDone()).toBe(true);
});

test("should send a single token request for concurrent access token reads", async () => {
const apiTokenIssuer = "issuer.fga.example";
const expectedBaseUrl = "https://issuer.fga.example";
Expand Down