diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 99841d39..ef1c6306 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -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; diff --git a/errors.ts b/errors.ts index 323553de..e99b4dbd 100644 --- a/errors.ts +++ b/errors.ts @@ -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 @@ -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; } } diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index e21a268f..331af9aa 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -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({}); @@ -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", + }); + + 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";