feat: reject token if it's not valid
This commit is contained in:
@@ -1,26 +1,171 @@
|
||||
import { GoogleToken } from "gtoken";
|
||||
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"],
|
||||
});
|
||||
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;
|
||||
projectId: string;
|
||||
privateKeyId: string;
|
||||
privateKey: string;
|
||||
clientEmail: string;
|
||||
clientID: string;
|
||||
authURI: string;
|
||||
tokenURI: string;
|
||||
authProviderX509CERTURL: string;
|
||||
clientX509CERTURL: string;
|
||||
authProviderX509CertUrl: string;
|
||||
clientX509CertUrl: string;
|
||||
universeDomain: string;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function sendFcmMessage(
|
||||
isOnlyValidatingFcmMessage?: boolean,
|
||||
) {
|
||||
return fetch(
|
||||
`https://fcm.googleapis.com/v1/projects/${adminSdkJson.projectID}/messages:send`,
|
||||
`https://fcm.googleapis.com/v1/projects/${adminSdkJson.projectId}/messages:send`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user