Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
38 changes: 34 additions & 4 deletions src/messaging/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,13 @@ export const messagingClientErrorCode: { readonly [K in keyof typeof MessagingEr
},
THIRD_PARTY_AUTH_ERROR: {
code: MessagingErrorCode.THIRD_PARTY_AUTH_ERROR,
message: 'A message targeted to an iOS device could not be sent because the ' +
'required APNs SSL certificate was not uploaded or has expired. Check the validity of your ' +
'development and production certificates.',
// The backend documents this code as "APNs certificate or web push auth key was invalid or
// missing", so the message names both, and the project settings where they are configured.
// The previous wording covered only an APNs SSL certificate, which does not fit APNs auth
// keys or Web Push.
message: 'The message could not be sent because the APNs certificate or auth key, or the ' +
'web push auth key, configured for your Firebase project was invalid or missing. Check ' +
'the APNs and web push credentials in your Firebase project settings.',
},
TOO_MANY_TOPICS: {
code: MessagingErrorCode.TOO_MANY_TOPICS,
Expand Down Expand Up @@ -213,6 +217,22 @@ const MESSAGING_SERVER_TO_CLIENT_CODE: Record<string, keyof typeof MessagingErro
UNSPECIFIED_ERROR: 'UNKNOWN_ERROR',
};

/**
* @const {ReadonlySet<string>} Server error codes that name the provider credential explicitly.
*
* These all map to THIRD_PARTY_AUTH_ERROR and leave no doubt about which credential is at fault,
* so the canonical message can lead for them.
*
* `UNAUTHENTICATED` maps to the same client code but is deliberately absent. Without an FcmError
* detail it is the plain gateway rejection, which really can mean this SDK's own credential is
* bad, and prefixing APNs guidance there would point developers away from the actual fault.
*/
const PROVIDER_AUTH_SERVER_CODES: ReadonlySet<string> = new Set([
'THIRD_PARTY_AUTH_ERROR',
'APNS_AUTH_ERROR',
'InvalidApnsCredential',
]);

/**
* @const {Record<string, keyof typeof MessagingErrorCode>} Topic management (IID)
* server to client enum error codes.
Expand Down Expand Up @@ -257,7 +277,17 @@ export class FirebaseMessagingError extends FirebaseError {
clientCodeKey = MESSAGING_SERVER_TO_CLIENT_CODE[serverErrorCode];
}
const error: ErrorInfo = deepCopy((messagingClientErrorCode as any)[clientCodeKey]);
error.message = message || error.message;
// The server message is normally the more specific of the two, so it wins. The exception is
// the codes above: for those the backend commonly sends the generic gateway text ("Request is
// missing required authentication credential. Expected OAuth 2 access token, ..."), which
// describes the caller's own credential while the fault is an APNs or web push credential on
// the project, so on its own it sends developers to audit their service account. Lead with the
// canonical message and keep the server text after it.
if (message && PROVIDER_AUTH_SERVER_CODES.has(serverErrorCode ?? '')) {
error.message = `${error.message} Server message: "${message}"`;
} else {
error.message = message || error.message;
}

const rawData = serverError?.response?.data;
if (clientCodeKey === 'UNKNOWN_ERROR' && typeof rawData !== 'undefined') {
Expand Down
1 change: 1 addition & 0 deletions test/unit/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import './database/index.spec';
// Messaging
import './messaging/index.spec';
import './messaging/messaging.spec';
import './messaging/messaging-errors-internal.spec';

// Machine Learning
import './machine-learning/index.spec';
Expand Down
86 changes: 86 additions & 0 deletions test/unit/messaging/messaging-errors-internal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,92 @@ describe('messaging-errors-internal', () => {
});
});

// The gateway text the backend returns for these. It describes the caller's own credential,
// which is not what is at fault.
const GATEWAY_MESSAGE = 'Request is missing required authentication credential. Expected ' +
'OAuth 2 access token, login cookie or other valid authentication credential.';
const APNS_GUIDANCE = 'The message could not be sent because the APNs certificate or auth ' +
'key, or the web push auth key, configured for your Firebase project was invalid or ' +
'missing. Check the APNs and web push credentials in your Firebase project settings.';

/** Builds a JSON error response. `error` is spread in so cases can omit `message` entirely. */
function jsonError(status: number, error: object): RequestResponseError {
const mockResponse: Partial<RequestResponse> = {
status,
headers: {},
isJson: () => true,
data: { error }
};
return new RequestResponseError(mockResponse as RequestResponse);
}

/** An FcmError detail carrying the given code, as the v1 backend sends it. */
function fcmDetail(errorCode: string): object[] {
return [{ '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', errorCode }];
}

// Every server code that names the provider credential outright. They must behave alike: the
// backend picks between these aliases, and the guidance should not depend on which it sent.
// The HTTP status varies so the behavior cannot be keyed on it.
const providerAuthCases = [
{ name: 'THIRD_PARTY_AUTH_ERROR detail', status: 401,
error: { status: 'UNAUTHENTICATED', details: fcmDetail('THIRD_PARTY_AUTH_ERROR') } },
{ name: 'APNS_AUTH_ERROR detail', status: 404,
error: { status: 'INVALID_ARGUMENT', details: fcmDetail('APNS_AUTH_ERROR') } },
{ name: 'legacy InvalidApnsCredential', status: 400,
error: { status: 'InvalidApnsCredential' } },
];

providerAuthCases.forEach(({ name, status, error }) => {
it(`should lead with the APNs guidance for ${name}`, () => {
const err = createFirebaseError(jsonError(status, { ...error, message: GATEWAY_MESSAGE }));

expect(err.code).to.equal('messaging/third-party-auth-error');
// The whole message, so that weakening any part of the guidance fails here.
expect(err.message).to.equal(`${APNS_GUIDANCE} Server message: "${GATEWAY_MESSAGE}"`);
});

it(`should not append an empty server message for ${name}`, () => {
// No `message` key at all. Appending here would print `Server message: "undefined"`.
expect(createFirebaseError(jsonError(status, error)).message).to.equal(APNS_GUIDANCE);
// Present but empty, which the parser also treats as absent.
expect(createFirebaseError(jsonError(status, { ...error, message: '' })).message)
.to.equal(APNS_GUIDANCE);
});
});

it('should not claim an APNs fault for UNAUTHENTICATED without an FcmError detail', () => {
// Same client code, different cause: with no FcmError detail this is the plain gateway
// rejection, which really can mean the SDK's own credential is bad. The server message has
// to stand on its own here rather than being prefixed with APNs guidance.
const err = createFirebaseError(
jsonError(401, { status: 'UNAUTHENTICATED', message: GATEWAY_MESSAGE }));

expect(err.code).to.equal('messaging/third-party-auth-error');
expect(err.message).to.equal(GATEWAY_MESSAGE);
});

it('should still use the server message verbatim for other error codes', () => {
// Guards the change above from widening: only THIRD_PARTY_AUTH_ERROR is special-cased.
const mockResponse: Partial<RequestResponse> = {
status: 404,
headers: {},
isJson: () => true,
data: {
error: {
status: 'NOT_FOUND',
message: 'Requested entity was not found.'
}
}
};
const mockError = new RequestResponseError(mockResponse as RequestResponse);

const error = createFirebaseError(mockError);

expect(error.code).to.equal('messaging/registration-token-not-registered');
expect(error.message).to.equal('Requested entity was not found.');
});

it('should create FirebaseMessagingError for non-JSON response (400)', () => {
const mockResponse: Partial<RequestResponse> = {
status: 400,
Expand Down