diff --git a/credentials/credentials.ts b/credentials/credentials.ts index b350178f..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) { - (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; diff --git a/errors.ts b/errors.ts index 76291074..4f53dada 100644 --- a/errors.ts +++ b/errors.ts @@ -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 @@ -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; @@ -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; } diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index a465c794..bf1c51e4 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,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); + }); }); });