refactor: move fcm to gcloud folder

This commit is contained in:
2024-10-05 10:57:18 -04:00
parent e4ca45dbdc
commit 15c75eea5b
10 changed files with 12 additions and 12 deletions

View File

@@ -0,0 +1,171 @@
import type { GetTokenOptions, TokenData as GoogleTokenData } from "gtoken";
import { SignJWT, importPKCS8 } from "jose";
import { DateTime } from "luxon";
import { lazy } from "../lazy";
export async function getGoogleAuthToken(adminSdkJson: AdminSdkCredentials) {
const { privateKey, clientEmail } = adminSdkJson;
const gToken = new GoogleToken(
{
key: privateKey,
email: clientEmail,
scope: ["https://www.googleapis.com/auth/firebase.messaging"],
},
adminSdkJson,
);
return gToken.getToken().then((token) => token.access_token);
}
const GOOGLE_TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token";
class GoogleToken {
#inFlightRequest?: Promise<GoogleTokenData>;
#tokenData?: TokenData;
#scope = lazy(() => {
const scope = this.options.scope;
if (Array.isArray(scope)) {
return scope;
} else if (typeof scope === "string") {
return [scope];
} else {
return [];
}
});
constructor(
private options: TokenOptions,
credentialsJson: AdminSdkCredentials,
) {
this.options.key = this.options.key ?? credentialsJson.privateKey;
this.options.email = this.options.email ?? credentialsJson.clientEmail;
}
getToken({
forceRefresh = false,
...options
}: GetTokenOptions = {}): Promise<GoogleTokenData> {
return this.#getTokenAsync({ ...options, forceRefresh });
}
isTokenExpiring(): boolean {
const now = DateTime.now();
const eagerRefreshThresholdMillis =
this.options.eagerRefreshThresholdMillis ?? 0;
if (this.#tokenData) {
return (
now.plus({ milliseconds: eagerRefreshThresholdMillis }) >=
this.#tokenData.expiresAt
);
}
return true;
}
#getTokenAsync(options: GetTokenOptions): Promise<GoogleTokenData> {
const { forceRefresh } = options;
if (this.#inFlightRequest && !forceRefresh) {
return this.#inFlightRequest;
}
try {
this.#inFlightRequest = this.#getTokenAsyncInternal(options);
return this.#inFlightRequest!;
} finally {
this.#inFlightRequest = undefined;
}
}
#getTokenAsyncInternal(options: GetTokenOptions): Promise<GoogleTokenData> {
if (!this.isTokenExpiring() && options.forceRefresh) {
return Promise.resolve(this.#tokenData!.token);
}
return this.#requestToken();
}
async #requestToken(): Promise<GoogleTokenData> {
if (!this.options.email) {
throw new Error("No email provided");
}
if (!this.options.key) {
throw new Error("No private key provided");
}
const issuedTokenAt = DateTime.now().toSeconds();
const additionalClaims = this.options.additionalClaims ?? {};
const jwtPayload = {
iss: this.options.email,
scope: this.#scope.get().join(" "),
aud: GOOGLE_TOKEN_URL,
exp: issuedTokenAt + 3600,
iat: issuedTokenAt,
additionalClaims,
sub: this.options.sub,
};
const key = await importPKCS8(this.options.key, "RS256");
const signedJwt = await new SignJWT(jwtPayload)
.setProtectedHeader({ alg: "RS256" })
.sign(key);
try {
const res = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: signedJwt,
}),
});
this.#tokenData = await res.json<GoogleTokenData>().then((data) => ({
token: data,
expiresAt: DateTime.fromSeconds(issuedTokenAt).plus({
seconds: data.expires_in,
}),
}));
return this.#tokenData!.token;
} catch (e) {
console.error(e);
throw e;
}
}
}
export interface TokenOptions {
keyFile?: string;
key?: string;
email?: string;
iss?: string;
sub?: string;
scope?: string | string[];
additionalClaims?: {};
/** Eagerly refresh the token if it is within this many milliseconds from expiring. Defaults to 0. */
eagerRefreshThresholdMillis?: number;
}
interface TokenData {
token: GoogleTokenData;
expiresAt: DateTime;
}
export interface AdminSdkCredentials {
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,76 @@
import {
type AdminSdkCredentials,
getGoogleAuthToken,
} from "./getGoogleAuthToken";
export async function sendFcmMessage(
adminSdkJson: AdminSdkCredentials,
message: FcmMessagePayload,
isOnlyValidatingFcmMessage?: boolean,
) {
return fetch(
`https://fcm.googleapis.com/v1/projects/${adminSdkJson.projectId}/messages:send`,
{
method: "POST",
body: JSON.stringify({
message,
validate_only: isOnlyValidatingFcmMessage,
}),
headers: {
Authorization: `Bearer ${await getGoogleAuthToken(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,51 @@
import { describe, expect, it } from "bun:test";
import { server } from "~/mocks";
import type { AdminSdkCredentials } from "./getGoogleAuthToken";
import { verifyFcmToken } from "./verifyFcmToken";
server.listen();
const FAKE_ADMIN_SDK_JSON: AdminSdkCredentials = {
type: "service_account",
projectId: "test-26g38",
privateKeyId: "privateKeyId",
privateKey: "privateKey",
clientEmail: "test@test.com",
clientID: "clientId",
authURI: "https://accounts.google.com/o/oauth2/auth",
tokenURI: "https://oauth2.googleapis.com/token",
authProviderX509CertUrl: "https://www.googleapis.com/oauth2/v1/certs",
clientX509CertUrl:
"https://www.googleapis.com/robot/v1/metadata/x509/test%40test.com",
universeDomain: "aniplay.com",
};
describe("verifyFcmToken", () => {
// it("valid token, returns true", async () => {
// const token =
// "7v8sy43aq0re4r8xe7rmr0cn1fsmh6phehnfla2pa73z899zmhyarivmkt4sj6pyv0py43u6p2sim6wz2vg9ypjp9rug1keoth7f6ll3gdvas4q020u3ah51r6bjgn51j6bd92ztmtof3ljpcm8q31njvndy65enm68";
// const res = await verifyFcmToken(token, FAKE_ADMIN_SDK_JSON);
// expect(res).toBeTrue();
// });
it("invalid token, returns false", async () => {
const token = "abc123";
const res = await verifyFcmToken(token, FAKE_ADMIN_SDK_JSON);
expect(res).toBeFalse();
});
it("invalid ADMIN_SDK_JSON, returns false", async () => {
const token =
"7v8sy43aq0re4r8xe7rmr0cn1fsmh6phehnfla2pa73z899zmhyarivmkt4sj6pyv0py43u6p2sim6wz2vg9ypjp9rug1keoth7f6ll3gdvas4q020u3ah51r6bjgn51j6bd92ztmtof3ljpcm8q31njvndy65enm68";
const res = await verifyFcmToken(token, {
...FAKE_ADMIN_SDK_JSON,
clientEmail: "",
});
expect(res).toBeFalse();
});
});

View File

@@ -0,0 +1,27 @@
import type { AdminSdkCredentials } from "./getGoogleAuthToken";
import { sendFcmMessage } from "./sendFcmMessage";
export async function verifyFcmToken(
token: string,
adminSdkJson: AdminSdkCredentials,
): 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;
});
}