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
19 changes: 14 additions & 5 deletions credentials/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,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 on lines 238 to 256
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshAccessToken() only wraps errors that are instanceof FgaApiError. However attemptHttpRequest() throws FgaApiAuthenticationError directly for 401/403, so token-refresh failures with those statuses will bypass this wrapper and lose the intended auth context (clientId, audience, grantType). Consider also catching/wrapping FgaApiAuthenticationError here (or adjusting the retry helper) so token endpoint 401/403 include the same context as other failures.

Copilot uses AI. Check for mistakes.
Expand Down
67 changes: 56 additions & 11 deletions errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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 @@ -209,8 +232,35 @@ export class FgaApiAuthenticationError extends FgaError {
public requestId?: string;
public apiErrorCode?: string;

constructor(err: AxiosError) {
super(`FGA Authentication Error.${err.response?.statusText ? ` ${err.response.statusText}` : ""}`);
constructor(err: AxiosError | FgaApiError, context?: {
clientId?: string;
audience?: string;
grantType?: string;
}) {
super(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;
Expand All @@ -222,15 +272,10 @@ export class FgaApiAuthenticationError extends FgaError {
const errResponseHeaders = getResponseHeaders(err);
this.requestId = errResponseHeaders[cFGARequestId];

let data: any;
try {
data = JSON.parse(err.config?.data || "{}");
} catch (err) {
/* do nothing */
}
this.clientId = data?.client_id;
this.audience = data?.audience;
this.grantType = data?.grant_type;
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;
}
Expand Down
87 changes: 86 additions & 1 deletion tests/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,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 @@ -537,5 +537,90 @@ 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}`;

const scope = nock(expectedBaseUrl)
.post(expectedPath)
.times(4)
.reply(500, {
code: "internal_error",
message: "token exchange failed",
});

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

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);
});
});
});
Loading