Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Enhance the "unauthorized" error message with recovery instructions #12652

Merged
merged 14 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from 12 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
120 changes: 120 additions & 0 deletions packages/api-graphql/__tests__/GraphQLAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,76 @@ describe('API test', () => {
expect(thread).toEqual(graphqlResponse.data.getThread);
});

test('auth-error-case', async () => {
expect.assertions(1);
Amplify.configure({
API: {
GraphQL: {
defaultAuthMode: 'apiKey',
apiKey: 'FAKE-KEY',
endpoint: 'https://localhost/graphql',
region: 'local-host-h4x',
},
},
});

const threadToGet = {
id: 'some-id',
topic: 'something reasonably interesting',
};

const graphqlVariables = { id: 'some-id' };

const err = {
name: 'GraphQLError',
message: 'Unknown error',
originalError: {
name: 'UnauthorizedException',
underlyingError: {
name: 'UnauthorizedException',
$metadata: {
httpStatusCode: 401,
requestId: '12345abcde-test-error-id',
},
},
$metadata: {
httpStatusCode: 401,
requestId: '12345abcde-test-error-id',
},
},
};

const spy = jest
.spyOn((raw.GraphQLAPI as any)._api, 'post')
.mockImplementation(() => {
return {
body: {
json: () => ({
errors: [err],
}),
},
};
});

try {
const result: GraphQLResult<GetThreadQuery> = await client.graphql({
query: typedQueries.getThread,
variables: graphqlVariables,
authMode: 'apiKey',
});
} catch (e: any) {
const errors = e.errors;
expect(errors).toEqual([
expect.objectContaining({
message: 'Unauthorized',
recoverySuggestion: expect.stringContaining(
`If you're calling an Amplify-generated API, make sure to set the "authMode" in generateClient`
),
}),
]);
}
});

test('cancel-graphql-query', async () => {
Amplify.configure({
API: {
Expand Down Expand Up @@ -942,6 +1012,56 @@ describe('API test', () => {
expect(observable).not.toBe(undefined);
});

test('subscription auth permissions error', done => {
expect.assertions(3);

const spyon_appsync_realtime = jest
.spyOn(AWSAppSyncRealTimeProvider.prototype, 'subscribe')
.mockImplementation(
jest.fn(() => {
return new Observable(observer => {
observer.error({
errors: [
{
message:
'Connection failed: {"errors":[{"errorType":"UnauthorizedException","message":"Permission denied"}]}',
},
],
});
});
})
);

const query = `subscription SubscribeToEventComments($eventId: String!) {
subscribeToEventComments(eventId: $eventId) {
eventId
commentId
content
}
}`;

const variables = { id: '809392da-ec91-4ef0-b219-5238a8f942b2' };

const observable = (
client.graphql({ query, variables }) as unknown as Observable<object>
).subscribe({
error: e => {
expect(spyon_appsync_realtime).toHaveBeenCalledTimes(1);
expect(e.errors).toEqual([
expect.objectContaining({
message: 'Unauthorized',
recoverySuggestion: expect.stringContaining(
`If you're calling an Amplify-generated API, make sure to set the "authMode" in generateClient`
),
}),
]);
done();
},
});

expect(observable).not.toBe(undefined);
});

test('happy case subscription with additionalHeaders', done => {
const spyon_appsync_realtime = jest
.spyOn(AWSAppSyncRealTimeProvider.prototype, 'subscribe')
Expand Down
14 changes: 11 additions & 3 deletions packages/api-graphql/src/internals/InternalGraphQLAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
GraphQLError,
OperationTypeNode,
} from 'graphql';
import { Observable } from 'rxjs';
import { Observable, catchError } from 'rxjs';
import { AmplifyClassV6, ConsoleLogger } from '@aws-amplify/core';
import {
GraphQLAuthMode,
Expand All @@ -31,6 +31,7 @@ import {
import { AWSAppSyncRealTimeProvider } from '../Providers/AWSAppSyncRealTimeProvider';
import { CustomHeaders } from '@aws-amplify/data-schema-types';
import { resolveConfig, resolveLibraryOptions } from '../utils';
import { repackageUnauthError } from '../utils/errors/repackageAuthError';

const USER_AGENT_HEADER = 'x-amz-user-agent';

Expand Down Expand Up @@ -391,7 +392,7 @@ export class InternalGraphQLAPIClass {
const { errors } = response;

if (errors && errors.length) {
throw response;
throw repackageUnauthError(response, authMode);
}

return response;
Expand Down Expand Up @@ -447,7 +448,14 @@ export class InternalGraphQLAPIClass {
libraryConfigHeaders,
},
customUserAgentDetails
);
).pipe(
catchError(e => {
if (e.errors) {
throw repackageUnauthError(e, authMode);
}
throw e;
})
);
stocaaro marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down
49 changes: 49 additions & 0 deletions packages/api-graphql/src/utils/errors/repackageAuthError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import {
AmplifyErrorParams,
GraphQLAuthMode,
} from '@aws-amplify/core/internals/utils';

type ErrorObject = {
errors: AmplifyErrorParams[];
};

/**
* Checks to see if the given response or subscription message contains an
* unauth error. If it does, it changes the error message to include instructions
* for the app developer.
*/
export function repackageUnauthError<T extends ErrorObject>(
content: T,
authMode?: GraphQLAuthMode
stocaaro marked this conversation as resolved.
Show resolved Hide resolved
): T {
if (content.errors && Array.isArray(content.errors)) {
content.errors.forEach(e => {
if (isUnauthError(e)) {
e.message = 'Unauthorized';
e.recoverySuggestion =
`If you're calling an Amplify-generated API, make sure ` +
`to set the "authMode" in generateClient({ authMode: '...' }) to the backend authorization ` +
`rule's auth provider ('apiKey', 'userPool', 'iam', 'oidc', 'lambda')`;
}
});
}
return content;
}

function isUnauthError(error: any): boolean {
// Error pattern corresponding to appsync calls
if (error?.['originalError']?.['name']?.startsWith('UnauthorizedException')) {
return true;
}
// Error pattern corresponding to appsync subscriptions
if (
error.message?.startsWith('Connection failed:') &&
error.message?.includes('Permission denied')
) {
return true;
}
return false;
}
Loading