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 3 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
118 changes: 118 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,75 @@ 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: expect.stringContaining(
`UnauthorizedError: 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 +1011,55 @@ 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: expect.stringContaining(
`UnauthorizedError: 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
42 changes: 26 additions & 16 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 @@ -236,7 +237,7 @@ export class InternalGraphQLAPIClass {
endpoint: appSyncGraphqlEndpoint,
customEndpoint,
customEndpointRegion,
defaultAuthMode
defaultAuthMode,
} = resolveConfig(amplify);

const authMode = explicitAuthMode || defaultAuthMode || 'iam';
Expand Down Expand Up @@ -391,7 +392,7 @@ export class InternalGraphQLAPIClass {
const { errors } = response;

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

return response;
Expand Down Expand Up @@ -424,19 +425,28 @@ export class InternalGraphQLAPIClass {
): Observable<any> {
const config = resolveConfig(amplify);

return this.appSyncRealTime.subscribe(
{
query: print(query as DocumentNode),
variables,
appSyncGraphqlEndpoint: config?.endpoint,
region: config?.region,
authenticationType: authMode || config?.defaultAuthMode,
apiKey: config?.apiKey,
additionalHeaders,
authToken,
},
customUserAgentDetails
);
return this.appSyncRealTime
.subscribe(
{
query: print(query as DocumentNode),
variables,
appSyncGraphqlEndpoint: config?.endpoint,
region: config?.region,
authenticationType: authMode || config?.defaultAuthMode,
apiKey: config?.apiKey,
additionalHeaders,
authToken,
},
customUserAgentDetails
)
.pipe(
catchError(e => {
if (e.errors) {
throw repackageUnauthError(e);
}
throw e;
})
);
}
}

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

import { GraphQLError } from 'graphql';

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

export function repackageUnauthError<T extends ErrorObject>(content: T): T {
stocaaro marked this conversation as resolved.
Show resolved Hide resolved
if (content.errors && Array.isArray(content.errors)) {
content.errors.forEach(e => {
if (isUnauthError(e)) {
e.message =
`UnauthorizedError: 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')`;
AllanZhengYP marked this conversation as resolved.
Show resolved Hide resolved
stocaaro marked this conversation as resolved.
Show resolved Hide resolved
}
});
}
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