From 52879156426d5b5e31704b17f11be1d16117cafe Mon Sep 17 00:00:00 2001 From: Changhyun Kim Date: Thu, 13 Aug 2026 14:09:40 +0900 Subject: [PATCH] fix(messaging): surface the APNs guidance for THIRD_PARTY_AUTH_ERROR When FCM rejects a send with a provider-auth error, the accompanying server message is often the generic gateway text ("Request is missing required authentication credential. Expected OAuth 2 access token, login cookie or other valid authentication credential."). fromServerError() lets any server message replace the canonical one, so that text is what developers see. It describes the caller's own credential, while the fault is an APNs or web push credential on the project, so it sends people to audit their service account instead. The SDK's own message for this code was unreachable in practice. Lead with the canonical message for the server codes that name the provider credential outright -- THIRD_PARTY_AUTH_ERROR, APNS_AUTH_ERROR and the legacy InvalidApnsCredential -- and keep the server text after it. UNAUTHENTICATED maps to the same client code but is deliberately excluded. Without an FcmError detail it is the plain gateway rejection, which really can mean this SDK's own credential is bad, so prefixing APNs guidance there would point developers away from the actual fault. Keying on the server code rather than the mapped client code keeps the two apart. Also widen the canonical message, which named only an "APNs SSL certificate" and development/production certificates. The backend documents this code as "APNs certificate or web push auth key was invalid or missing", which also covers APNs auth keys and Web Push. test/unit/index.spec.ts did not import messaging-errors-internal.spec, so that file never ran under `npm test`. Wired it in; the unit suite goes from 6064 to 6076 tests. Fixes #3215 --- src/messaging/error.ts | 38 +++++++- test/unit/index.spec.ts | 1 + .../messaging-errors-internal.spec.ts | 86 +++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/messaging/error.ts b/src/messaging/error.ts index 6df577f591..27de36af3b 100644 --- a/src/messaging/error.ts +++ b/src/messaging/error.ts @@ -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, @@ -213,6 +217,22 @@ const MESSAGING_SERVER_TO_CLIENT_CODE: Record} 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 = new Set([ + 'THIRD_PARTY_AUTH_ERROR', + 'APNS_AUTH_ERROR', + 'InvalidApnsCredential', +]); + /** * @const {Record} Topic management (IID) * server to client enum error codes. @@ -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') { diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 360bac68e2..4911cc4bdb 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -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'; diff --git a/test/unit/messaging/messaging-errors-internal.spec.ts b/test/unit/messaging/messaging-errors-internal.spec.ts index 360fe0716b..fbf5821021 100644 --- a/test/unit/messaging/messaging-errors-internal.spec.ts +++ b/test/unit/messaging/messaging-errors-internal.spec.ts @@ -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 = { + 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 = { + 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 = { status: 400,