From 2362e8c9bdcbc02473dd117c73fde09dece3e4c6 Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Mon, 16 Feb 2026 15:52:52 -0300 Subject: [PATCH 1/6] test: reproduce token refresh error retyping bug --- tests/credentials.test.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index a465c794..d8e59ff0 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -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({}); @@ -537,5 +537,36 @@ 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, + ); + + await expect(credentials.getAccessTokenHeader()).rejects.toThrow(FgaApiAuthenticationError); + expect(scope.isDone()).toBe(true); + }); }); }); From e30c92ed292f4ff05de7c6ef2c90a92ae237a44e Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Mon, 16 Feb 2026 15:53:27 -0300 Subject: [PATCH 2/6] fix: throw real authentication error on token refresh failures --- credentials/credentials.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index b350178f..09a60d66 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -237,11 +237,25 @@ export class Credentials { return this.accessToken; } catch (err: unknown) { 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"; + const authError = new FgaApiAuthenticationError({ + response: { + status: err.statusCode, + statusText: err.statusText, + data: err.responseData, + headers: err.responseHeader, + }, + config: { + url: err.requestURL, + method: err.method, + data: JSON.stringify({ + client_id: clientCredentials.clientId, + audience: clientCredentials.apiAudience, + grant_type: "client_credentials", + }), + }, + } as any); + authError.stack = err.stack; + throw authError; } throw err; From 8cfe19fb845fc4d71981f1b3c6240cb6091c59c8 Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Mon, 16 Feb 2026 18:13:44 -0300 Subject: [PATCH 3/6] fix: tighten auth error conversion and speed retry repro test --- credentials/credentials.ts | 24 +++++---------------- errors.ts | 43 ++++++++++++++++++++++++++++++++++++-- tests/credentials.test.ts | 5 ++--- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 09a60d66..835e3481 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -237,25 +237,11 @@ export class Credentials { return this.accessToken; } catch (err: unknown) { if (err instanceof FgaApiError) { - const authError = new FgaApiAuthenticationError({ - response: { - status: err.statusCode, - statusText: err.statusText, - data: err.responseData, - headers: err.responseHeader, - }, - config: { - url: err.requestURL, - method: err.method, - data: JSON.stringify({ - client_id: clientCredentials.clientId, - audience: clientCredentials.apiAudience, - grant_type: "client_credentials", - }), - }, - } as any); - authError.stack = err.stack; - throw authError; + throw new FgaApiAuthenticationError(err, { + clientId: clientCredentials.clientId, + audience: clientCredentials.apiAudience, + grantType: "client_credentials", + }); } throw err; diff --git a/errors.ts b/errors.ts index 76291074..60147e02 100644 --- a/errors.ts +++ b/errors.ts @@ -54,6 +54,11 @@ function getResponseHeaders(err: AxiosError): any { : {}; } +function getAuthenticationErrorMessage(err: AxiosError | FgaApiError): string { + const statusText = err instanceof FgaApiError ? err.statusText : err.response?.statusText; + return `FGA Authentication Error.${statusText ? ` ${statusText}` : ""}`; +} + /** * * @export @@ -209,8 +214,42 @@ 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; + + let data: any = err.requestData; + if (typeof data === "string") { + try { + data = JSON.parse(data); + } catch { + data = undefined; + } + } + + 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; diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index d8e59ff0..0e679fd8 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -545,9 +545,8 @@ describe("Credentials", () => { const scope = nock(expectedBaseUrl) .post(expectedPath) - .times(4) - .reply(500, { - code: "internal_error", + .reply(404, { + code: "not_found", message: "token exchange failed", }); From f9d859436857bed2e9182b8f704e72fc8c1f5ec3 Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Mon, 16 Feb 2026 19:00:42 -0300 Subject: [PATCH 4/6] fix: honor auth error context for axios failures --- errors.ts | 6 +++--- tests/credentials.test.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/errors.ts b/errors.ts index 60147e02..b7505423 100644 --- a/errors.ts +++ b/errors.ts @@ -267,9 +267,9 @@ export class FgaApiAuthenticationError extends FgaError { } catch (err) { /* do nothing */ } - this.clientId = data?.client_id; - this.audience = data?.audience; - this.grantType = data?.grant_type; + 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; } diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 0e679fd8..42e0112c 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -564,7 +564,19 @@ describe("Credentials", () => { mockTelemetryConfig, ); - await expect(credentials.getAccessTokenHeader()).rejects.toThrow(FgaApiAuthenticationError); + let error: unknown; + try { + await credentials.getAccessTokenHeader(); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(FgaApiAuthenticationError); + const authenticationError = error as FgaApiAuthenticationError; + expect(authenticationError.statusCode).toBe(404); + 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); }); }); From 40c23cceaba477b2bb4b573dfade62a0f15b2290 Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Tue, 17 Feb 2026 09:55:09 -0300 Subject: [PATCH 5/6] test: align auth refresh regression with 500 scenario --- errors.ts | 6 +++--- tests/credentials.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/errors.ts b/errors.ts index b7505423..4497b5a5 100644 --- a/errors.ts +++ b/errors.ts @@ -240,9 +240,9 @@ export class FgaApiAuthenticationError extends FgaError { } } - this.clientId = context?.clientId || data?.client_id; - this.audience = context?.audience || data?.audience; - this.grantType = context?.grantType || data?.grant_type; + 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; diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 42e0112c..34a0624a 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -545,8 +545,9 @@ describe("Credentials", () => { const scope = nock(expectedBaseUrl) .post(expectedPath) - .reply(404, { - code: "not_found", + .times(4) + .reply(500, { + code: "internal_error", message: "token exchange failed", }); @@ -573,7 +574,7 @@ describe("Credentials", () => { expect(error).toBeInstanceOf(FgaApiAuthenticationError); const authenticationError = error as FgaApiAuthenticationError; - expect(authenticationError.statusCode).toBe(404); + 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); From b54331ccdaf2f6d3712ab3bf9ee752ff4092f1e6 Mon Sep 17 00:00:00 2001 From: Andres Aguiar Date: Tue, 17 Feb 2026 10:20:15 -0300 Subject: [PATCH 6/6] fix: preserve auth context for 401 token refresh failures --- credentials/credentials.ts | 19 ++++++++++++----- errors.ts | 34 +++++++++++++++++------------- tests/credentials.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 835e3481..367f8ce3 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -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) { - throw new FgaApiAuthenticationError(err, { - clientId: clientCredentials.clientId, - audience: clientCredentials.apiAudience, - grantType: "client_credentials", - }); + throw new FgaApiAuthenticationError(err, authErrorContext); } throw err; diff --git a/errors.ts b/errors.ts index 4497b5a5..4f53dada 100644 --- a/errors.ts +++ b/errors.ts @@ -54,6 +54,24 @@ 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}` : ""}`; @@ -231,14 +249,7 @@ export class FgaApiAuthenticationError extends FgaError { this.requestId = err.requestId; this.apiErrorCode = (err.responseData as any)?.code; - let data: any = err.requestData; - if (typeof data === "string") { - try { - data = JSON.parse(data); - } catch { - data = undefined; - } - } + const data: any = parseRequestData(err.requestData); this.clientId = context?.clientId ?? data?.client_id; this.audience = context?.audience ?? data?.audience; @@ -261,12 +272,7 @@ 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 */ - } + 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; diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 34a0624a..bf1c51e4 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -580,5 +580,47 @@ describe("Credentials", () => { 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); + }); }); });