feat: create lib function to verify FCM token

This commit is contained in:
2024-06-15 05:47:26 -04:00
parent 20ca88fda9
commit 7675867549
10 changed files with 219 additions and 1 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -24,6 +24,7 @@
"drizzle-orm": "^0.31.2",
"gql.tada": "^1.7.5",
"graphql-request": "^7.0.1",
"gtoken": "^7.1.0",
"hono": "^4.3.6",
"zod": "^3.23.8"
},

View File

@@ -174,7 +174,6 @@ describe("requests the /token route", () => {
});
it("token already exists in db, should not insert new entry", async () => {
const minimumTimestamp = DateTime.now();
await db
.insert(deviceTokensTable)
.values({ deviceId: "123", token: "123" });

View File

@@ -0,0 +1,26 @@
import { GoogleToken } from "gtoken";
export async function getGoogleAuthToken(adminSdkJson: AdminSdkKey) {
const { privateKey, clientEmail } = adminSdkJson;
const gToken = new GoogleToken({
key: privateKey,
email: clientEmail,
scope: ["https://www.googleapis.com/auth/firebase.messaging"],
});
return gToken.getToken().then((token) => token.access_token);
}
interface AdminSdkKey {
type: string;
projectID: string;
privateKeyID: string;
privateKey: string;
clientEmail: string;
clientID: string;
authURI: string;
tokenURI: string;
authProviderX509CERTURL: string;
clientX509CERTURL: string;
universeDomain: string;
}

View File

@@ -0,0 +1,73 @@
import { getGoogleAuthToken } from "./getGoogleAuthToken";
export async function sendFcmMessage(
adminSdkJson: string,
message: FcmMessagePayload,
isOnlyValidatingFcmMessage?: boolean,
) {
return fetch(
"https://fcm.googleapis.com/v1/projects/aniplay-73b59/messages:send",
{
method: "POST",
body: JSON.stringify({
message,
validate_only: isOnlyValidatingFcmMessage,
}),
headers: {
Authorization: `Bearer ${await getGoogleAuthToken(JSON.parse(adminSdkJson))}`,
},
},
).then((res) => res.json<SendFcmMessageResponse>());
}
type SendFcmMessageResponse =
| { error: SendFcmMessageError }
| FcmMessagePayload;
interface SendFcmMessageError {
code: number;
message: string;
status: string;
details: Detail[];
}
export interface Detail {
type: string;
errorCode: string;
}
export type FcmMessagePayload = {
name?: string;
data?: Record<string, string>;
notification?: Notification;
android?: Partial<AndroidConfig>;
webpush?: {};
apns?: {};
fcm_options?: {};
} & (
| { token: string; topic?: never; condition?: never }
| { token?: never; topic: string; condition?: never }
| { token?: never; topic?: never; condition: string }
);
interface Notification {
title: string;
body: string;
image: string;
}
interface AndroidConfig {
collapse_key: string;
priority: "normal" | "high";
/**
* How long (in seconds) the message should be kept in FCM storage if the device is offline. The maximum time to live supported is 4 weeks, and the default value is 4 weeks if not set. Set it to 0 if want to send the message immediately. In JSON format, the Duration type is encoded as a string rather than an object, where the string ends in the suffix "s" (indicating seconds) and is preceded by the number of seconds, with nanoseconds expressed as fractional seconds. For example, 3 seconds with 0 nanoseconds should be encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should be expressed in JSON format as "3.000000001s". The ttl will be rounded down to the nearest second.
*
* A duration in seconds with up to nine fractional digits, ending with 's'. Example: "3.5s".
*/
ttl: string;
restricted_package_name: string;
data: Record<string, string>;
notification: {};
fcm_options: {};
direct_boot_ok: boolean;
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "bun:test";
import { server } from "~/mocks";
import "~/mocks/gToken";
import { verifyFcmToken } from "./verifyFcmToken";
server.listen();
describe("verifyFcmToken", () => {
it("valid token, returns true", async () => {
const token =
"7v8sy43aq0re4r8xe7rmr0cn1fsmh6phehnfla2pa73z899zmhyarivmkt4sj6pyv0py43u6p2sim6wz2vg9ypjp9rug1keoth7f6ll3gdvas4q020u3ah51r6bjgn51j6bd92ztmtof3ljpcm8q31njvndy65enm68";
const res = await verifyFcmToken(token, '{"clientEmail": "test@test.com"}');
expect(res).toBeTrue();
});
it("invalid token, returns false", async () => {
const token = "abc123";
const res = await verifyFcmToken(token, '{"clientEmail": "test@test.com"}');
expect(res).toBeFalse();
});
});

View File

@@ -0,0 +1,26 @@
import { sendFcmMessage } from "./sendFcmMessage";
export async function verifyFcmToken(
token: string,
adminSdkJson: string,
): Promise<boolean> {
return sendFcmMessage(
adminSdkJson,
{ name: "token_verification", token },
true,
)
.then((response) => {
const error = "error" in response ? response.error : undefined;
if (error) {
console.error("Received error response while validating FCM token");
console.error(JSON.stringify(error));
}
return !error;
})
.catch((err) => {
console.error("Failed to verify FCM token", err);
return false;
});
}

36
src/mocks/fcm.ts Normal file
View File

@@ -0,0 +1,36 @@
import { HttpResponse, http } from "msw";
import type { FcmMessagePayload } from "~/libs/fcm/sendFcmMessage";
export function mockFcmMessageResponse() {
return http.post<{}, { message: FcmMessagePayload; validate_only: boolean }>(
"https://fcm.googleapis.com/v1/projects/aniplay-73b59/messages:send",
async ({ request }) => {
const { message } = await request.json();
const { name, token } = message;
if (name === "token_verification") {
if (token?.length === 163) {
return HttpResponse.json({ name });
}
return HttpResponse.json({
error: {
code: 400,
message:
"The registration token is not a valid FCM registration token",
status: "INVALID_ARGUMENT",
details: [
{
"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError",
errorCode: "INVALID_ARGUMENT",
},
],
},
});
}
return HttpResponse.json(message);
},
);
}

30
src/mocks/gToken.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { TokenOptions } from "gtoken";
import { mock } from "bun:test";
const emailRegex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
class MockGoogleToken {
private email: string | undefined;
constructor(options: TokenOptions) {
this.email = options.email;
}
getToken() {
if (!this.email) {
return Promise.reject("No email provided");
}
if (!emailRegex.test(this.email)) {
return Promise.reject("Invalid email");
}
return Promise.resolve({
access_token: "asd",
});
}
}
mock.module("gtoken", () => ({ GoogleToken: MockGoogleToken }));

View File

@@ -8,6 +8,7 @@ import { getAnifyTitle } from "./anify/title";
import { getAnilistSearchResults } from "./anilist/search";
import { getAnilistTitle } from "./anilist/title";
import { updateAnilistWatchStatus } from "./anilist/updateWatchStatus";
import { mockFcmMessageResponse } from "./fcm";
export const handlers = [
getAnilistSearchResults(),
@@ -20,4 +21,5 @@ export const handlers = [
getAnifyEpisodes(),
getAnifySources(),
getAnifyTitle(),
mockFcmMessageResponse(),
];