in src/app/api/v1/fxa-rp-events/route.ts [122:455]
export async function POST(request: NextRequest) {
let decodedJWT: JwtPayload;
try {
decodedJWT = (await authenticateFxaJWT(request)) as JwtPayload;
} catch (e) {
logger.error("fxa_rp_event", { exception: e as string });
captureException(e);
return NextResponse.json({ success: false }, { status: 401 });
}
if (!decodedJWT?.events) {
// capture an exception in Sentry only. Throwing error will trigger FXA retry
logger.error("fxa_rp_event", { decodedJWT });
captureMessage(
`fxa_rp_event: decodedJWT is missing attribute "events", ${
decodedJWT as unknown as string
}`,
);
return NextResponse.json(
{
success: false,
message: 'fxa_rp_event: decodedJWT is missing attribute "events"',
},
{ status: 400 },
);
}
const fxaUserId = decodedJWT?.sub;
if (!fxaUserId) {
// capture an exception in Sentry only. Throwing error will trigger FXA retry
captureMessage(
`fxa_rp_event: decodedJWT is missing attribute "sub", ${
decodedJWT as unknown as string
}`,
);
return NextResponse.json(
{
success: false,
message: 'fxa_rp_event: decodedJWT is missing attribute "sub"',
},
{ status: 400 },
);
}
const subscriber = await getSubscriberByFxaUid(fxaUserId);
// highly unlikely, though it is a possible edge case from QA tests.
// To reproduce, perform the following two actions in sequence very quickly in FxA settings portal:
// 1. swap primary email and secondary email
// 2. quickly follow step 1 with deleting the account
// There's a chance that the fxa event from deletion gets to our service first, in which case, the user will be deleted from the db prior to the profile change event hitting our service
if (!subscriber) {
const e = new Error(
`could not find subscriber with fxa user id: ${fxaUserId}`,
);
logger.error("fxa_rp_event", { exception: e.message });
return NextResponse.json({ success: true, message: "OK" }, { status: 200 });
}
// reference example events: https://github.com/mozilla/fxa/blob/main/packages/fxa-event-broker/README.md
for (const event in decodedJWT?.events) {
switch (event) {
case FXA_DELETE_USER_EVENT: {
await deleteAccount(subscriber);
break;
}
case FXA_PROFILE_CHANGE_EVENT: {
const updatedProfileFromEvent = decodedJWT.events[
event
] as ProfileChangeEvent;
logger.info("fxa_profile_update", {
subscriber_id: subscriber.id,
event,
updatedProfileFromEvent,
});
record("account", "profile_change", {
string: {
monitorUserId: subscriber.id.toString(),
},
});
// get current profiledata
// Typed as `any` because `subscriber` used to be typed as `any`, and
// making that type more specific was enough work just by itself:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const currentFxAProfile: any = subscriber?.fxa_profile_json;
// merge new event into existing profile data
if (Object.keys(updatedProfileFromEvent).length !== 0) {
for (const key in updatedProfileFromEvent) {
// primary email change
if (key === "email") {
await updatePrimaryEmail(
subscriber,
updatedProfileFromEvent[key as keyof ProfileChangeEvent] ||
subscriber.primary_email,
);
}
if (currentFxAProfile && currentFxAProfile[key]) {
currentFxAProfile[key] =
updatedProfileFromEvent[key as keyof ProfileChangeEvent];
}
}
}
// update fxa profile data
await updateFxAProfileData(subscriber, currentFxAProfile);
break;
}
case FXA_PASSWORD_CHANGE_EVENT: {
const updateFromEvent = decodedJWT.events[event];
logger.info("fxa_password_change", {
subscriber: subscriber.id,
event,
updateFromEvent,
});
record("account", "password_change", {
string: {
monitorUserId: subscriber.id.toString(),
},
});
const refreshToken = subscriber.fxa_refresh_token ?? "";
const accessToken = subscriber.fxa_access_token ?? "";
if (!accessToken || !refreshToken) {
logger.error("failed_changing_password", {
subscriber_id: subscriber.id,
fxa_refresh_token: refreshToken,
fxa_access_token: accessToken,
});
}
// MNTOR-1932: Change password should revoke sessions
await revokeOAuthTokens({
fxa_access_token: accessToken,
fxa_refresh_token: refreshToken,
});
return NextResponse.json(
{ success: true, message: "session_revoked" },
{ status: 200 },
);
break;
}
case FXA_SUBSCRIPTION_CHANGE_EVENT: {
const updatedSubscriptionFromEvent = decodedJWT.events[
event
] as SubscriptionStateChangeEvent;
logger.info("fxa_subscription_change", {
subscriber: subscriber.id,
event,
updatedSubscriptionFromEvent,
});
try {
// get profile id
const oneRepProfileId = await getOnerepProfileId(subscriber.id);
logger.info("get_onerep_profile", {
subscriber_id: subscriber.id,
oneRepProfileId,
});
const enabledFeatureFlags = await getEnabledFeatureFlags({
isSignedOut: true,
});
if (
updatedSubscriptionFromEvent.isActive &&
updatedSubscriptionFromEvent.capabilities.includes(
MONITOR_PREMIUM_CAPABILITY,
)
) {
// Update fxa profile data to match subscription status.
// This is done before trying to activate the OneRep subscription, in case there are
// any problems with activation.
await changeSubscription(subscriber, true);
// Set monthly monitor report value back to true
await setMonthlyMonitorReport(subscriber, true);
// MNTOR-2103: if one rep profile id doesn't exist in the db, fail immediately
if (!oneRepProfileId) {
logger.error("onerep_profile_not_found", {
subscriber_id: subscriber.id,
});
captureMessage(
`User subscribed but no OneRep profile Id found, user: ${
subscriber.id
}\n
Event: ${event}\n
updateFromEvent: ${JSON.stringify(updatedSubscriptionFromEvent)}`,
);
return NextResponse.json(
{
success: true,
message: "failed_activating_subscription_profile_id_missing",
},
{ status: 200 },
);
}
// activate and opt out profiles
try {
await activateProfile(oneRepProfileId);
} catch (ex) {
if (
(ex as Error).message ===
"Failed to activate OneRep profile: [403] [Forbidden]"
)
logger.error("profile_already_activated", {
subscriber_id: subscriber.id,
exception: ex,
});
}
try {
await optoutProfile(oneRepProfileId);
} catch (ex) {
if (
(ex as Error).message ===
"Failed to opt-out OneRep profile: [403] [Forbidden]"
)
logger.error("profile_already_opted_out", {
subscriber_id: subscriber.id,
exception: ex,
});
}
logger.info("activated_onerep_profile", {
subscriber_id: subscriber.id,
});
record("subscription", "activate", {
string: {
monitorUserId: subscriber.id.toString(),
},
});
if (enabledFeatureFlags.includes("GA4SubscriptionEvents")) {
await sendPingToGA(subscriber.id, "subscribe");
}
} else if (
!updatedSubscriptionFromEvent.isActive &&
updatedSubscriptionFromEvent.capabilities.includes(
MONITOR_PREMIUM_CAPABILITY,
)
) {
// Update fxa profile data to match subscription status.
// This is done before trying to deactivate the OneRep subscription, in case there are
// any problems with deactivation.
await changeSubscription(subscriber, false);
// MNTOR-2103: if one rep profile id doesn't exist in the db, fail immediately
if (!oneRepProfileId) {
logger.error("onerep_profile_not_found", {
subscriber_id: subscriber.id,
});
captureMessage(
`No OneRep profile Id found, subscriber: ${subscriber.id}\n
Event: ${event}\n
updateFromEvent: ${JSON.stringify(
updatedSubscriptionFromEvent,
)}`,
);
return NextResponse.json(
{ success: true, message: "failed_deactivating_subscription" },
{ status: 200 },
);
}
// deactivation stops opt out process
try {
await deactivateProfile(oneRepProfileId);
} catch (ex) {
if (
(ex as Error).message ===
"Failed to deactivate OneRep profile: [403] [Forbidden]"
)
logger.error("profile_already_opted_out", {
subscriber_id: subscriber.id,
exception: ex,
});
}
logger.info("deactivated_onerep_profile", {
subscriber_id: subscriber.id,
});
record("subscription", "cancel", {
string: {
monitorUserId: subscriber.id.toString(),
},
});
if (enabledFeatureFlags.includes("GA4SubscriptionEvents")) {
await sendPingToGA(subscriber.id, "unsubscribe");
}
}
} catch (e) {
captureMessage(
`${(e as Error).message}\n
Event: ${event}\n
updateFromEvent: ${JSON.stringify(updatedSubscriptionFromEvent)}`,
);
logger.error("failed_activating_subscription", {
subscriber_id: subscriber.id,
message: (e as Error).message,
stack: (e as Error).stack,
});
return NextResponse.json(
{ success: false, message: "failed_activating_subscription" },
{ status: 500 },
);
}
break;
}
default:
logger.warn("unhandled_event", {
event,
});
break;
}
}
return NextResponse.json({ success: true, message: "OK" }, { status: 200 });
}