refactor: move fcm to gcloud folder
This commit is contained in:
171
src/libs/gcloud/getGoogleAuthToken.ts
Normal file
171
src/libs/gcloud/getGoogleAuthToken.ts
Normal 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;
|
||||
}
|
||||
76
src/libs/gcloud/sendFcmMessage.ts
Normal file
76
src/libs/gcloud/sendFcmMessage.ts
Normal 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;
|
||||
}
|
||||
51
src/libs/gcloud/verifyFcmToken.spec.ts
Normal file
51
src/libs/gcloud/verifyFcmToken.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
src/libs/gcloud/verifyFcmToken.ts
Normal file
27
src/libs/gcloud/verifyFcmToken.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user