diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 88babc6..ee92e69 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -28,3 +28,4 @@ jobs: - run: yarn test env: GOOGLE_CLOUD_CREDENTIALS: ${{ secrets.GOOGLE_CLOUD_CREDENTIALS }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} diff --git a/.gitignore b/.gitignore index 0981909..14b1d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Include your project-specific ignores in this file # Read about how to use .gitignore: https://help.github.com/articles/ignoring-files -# Compiled output +# Cache and compiled output +/.cache/ /dist/ # Yarn package manager diff --git a/.vscode/settings.json b/.vscode/settings.json index 78cb4d3..b0ac724 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,5 +31,34 @@ "**/yarn.lock": true, "**/.yarn": true, "**/.pnp.*": true - } + }, + "terminal.integrated.env.linux": { + "CACHE_DIR": "${workspaceFolder}/.cache" + }, + "terminal.integrated.env.osx": { + "CACHE_DIR": "${workspaceFolder}/.cache" + }, + "terminal.integrated.env.windows": { + "CACHE_DIR": "${workspaceFolder}\\.cache" + }, + "cSpell.words": [ + "async", + "await", + "cjs", + "endregion", + "esm", + "hono", + "identitytoolkit", + "jest", + "mjs", + "node", + "ts", + "tsconfig", + "tslib", + "tslint", + "tsnode", + "tsv", + "tsx", + "yarn" + ] } diff --git a/README.md b/README.md index 394fadf..a279d39 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Authentication Library for the Web +# Web Auth Library [![NPM Version](https://img.shields.io/npm/v/web-auth-library?style=flat-square)](https://www.npmjs.com/package/web-auth-library) [![NPM Downloads](https://img.shields.io/npm/dm/web-auth-library?style=flat-square)](https://www.npmjs.com/package/web-auth-library) @@ -6,7 +6,19 @@ [![Donate](https://img.shields.io/badge/dynamic/json?color=%23ff424d&label=Patreon&style=flat-square&query=data.attributes.patron_count&suffix=%20patrons&url=https%3A%2F%2Fwww.patreon.com%2Fapi%2Fcampaigns%2F233228)](http://patreon.com/koistya) [![Discord](https://img.shields.io/discord/643523529131950086?label=Chat&style=flat-square)](https://discord.gg/bSsv7XM) -A collection of utility functions for working with [Web Crypto API](https://developer.mozilla.org/docs/Web/API/Web_Crypto_API). +Authentication library for Google Cloud, Firebase, and other cloud providers that uses standard [Web Crypto API](https://developer.mozilla.org/docs/Web/API/Web_Crypto_API) and runs in different environments and runtimes, including but not limited to: + +- [Bun](https://bun.sh/) +- [Browsers](https://developer.mozilla.org/docs/Web/API/Web_Crypto_API) +- [Cloudflare Workers](https://workers.cloudflare.com/) +- [Deno](https://deno.land/) +- [Electron](https://www.electronjs.org/) +- [Node.js](https://nodejs.org/) +- [Vercel's Edge Runtime](https://edge-runtime.vercel.app/) + +It has minimum dependencies, small bundle size, and optimized for speed and performance. + +## Getting Stated ```bash # Install using NPM @@ -16,121 +28,93 @@ $ npm install web-auth-library --save $ yarn add web-auth-library ``` -## Usage Example +## Usage Examples -### Retrieving an access token from Google's OAuth 2.0 authorization server +### Verify the user ID Token issued by Google or Firebase + +**NOTE**: The `credentials` argument in the examples below is expected to be a serialized JSON string of a [Google Cloud service account key](https://cloud.google.com/iam/docs/creating-managing-service-account-keys), `apiKey` is Google Cloud API Key (Firebase API Key), and `projectId` is a Google Cloud project ID. ```ts -import { getAuthToken } from "web-auth-library/google"; +import { verifyIdToken } from "web-auth-library/google"; -const token = await getAuthToken({ +const token = await verifyIdToken({ + idToken, credentials: env.GOOGLE_CLOUD_CREDENTIALS, - scope: "https://www.googleapis.com/auth/cloud-platform", }); + // => { -// accessToken: "ya29.c.b0AXv0zTOQVv0...", -// type: "Bearer", -// expires: 1653855236, +// iss: 'https://securetoken.google.com/example', +// aud: 'example', +// auth_time: 1677525930, +// user_id: 'temp', +// sub: 'temp', +// iat: 1677525930, +// exp: 1677529530, +// firebase: {} // } - -return fetch("https://cloudresourcemanager.googleapis.com/v1/projects", { - headers: { - authorization: `Bearer ${token.accessToken}`, - }, -}); ``` -Where `env.GOOGLE_CLOUD_CREDENTIALS` is an environment variable / secret -containing a [service account key](https://cloud.google.com/iam/docs/creating-managing-service-account-keys) -(JSON) obtained from the [Google Cloud Platform](https://cloud.google.com/). - -#### Retrieving an ID token for the target audience +### Create an access token for accessing [Google Cloud APIs](https://developers.google.com/apis-explorer) ```ts -import { getAuthToken } from "web-auth-library/google"; +import { getAccessToken } from "web-auth-library/google"; -const token = await getAuthToken({ +// Generate a short lived access token from the service account key credentials +const accessToken = await getAccessToken({ credentials: env.GOOGLE_CLOUD_CREDENTIALS, - audience: "https://example.com", + scope: "https://www.googleapis.com/auth/cloud-platform", }); -// => { -// idToken: "eyJhbGciOiJSUzI1NiIsImtpZ...", -// audience: "https://example.com", -// expires: 1654199401, -// } -``` -#### Decoding an ID token - -```ts -import { jwt } from "web-auth-library/google"; - -jwt.decode(idToken); -// { -// header: { -// alg: 'RS256', -// kid: '38f3883468fc659abb4475f36313d22585c2d7ca', -// typ: 'JWT' -// }, -// payload: { -// iss: 'https://accounts.google.com', -// sub: '118363561738753879481' -// aud: 'https://example.com', -// azp: 'example@example.iam.gserviceaccount.com', -// email: 'example@example.iam.gserviceaccount.com', -// email_verified: true, -// exp: 1654199401, -// iat: 1654195801, -// }, -// data: 'eyJhbGciOiJ...', -// signature: 'MDzBStL...' -// } +// Make a request to one of the Google's APIs using that token +const res = await fetch( + "https://cloudresourcemanager.googleapis.com/v1/projects", + { + headers: { Authorization: `Bearer ${accessToken}` }, + } +); ``` -#### Verifying an ID token +## Create a custom ID token using Service Account credentials ```ts -import { verifyIdToken } from "web-auth-library/google"; +import { getIdToken } from "web-auth-library/google"; -const token = await verifyIdToken(idToken, { audience: "https://example.com" }); -// => { -// iss: 'https://accounts.google.com', -// aud: 'https://example.com', -// sub: '118363561738753879481' -// azp: 'example@example.iam.gserviceaccount.com', -// email: 'example@example.iam.gserviceaccount.com', -// email_verified: true, -// exp: 1654199401, -// iat: 1654195801, -// } +const idToken = await getIdToken({ + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + audience: "https://example.com", +}); ``` -#### Generating a digital signature +## An alternative way passing credentials + +Instead of passing credentials via `options.credentials` argument, you can also let the library pick up credentials from the list of environment variables using standard names such as `GOOGLE_CLOUD_CREDENTIALS`, `GOOGLE_CLOUD_PROJECT`, `FIREBASE_API_KEY`, for example: ```ts -import { getCredentials, importKey, sign } from "web-auth-library/google"; +import { verifyIdToken } from "web-auth-library/google"; -const credentials = getCredentials(env.GOOGLE_CLOUD_CREDENTIALS); -const signingKey = await importKey(credentials.private_key, ["sign"]); -const signature = await sign(signingKey, "xxx"); +const env = { GOOGLE_CLOUD_CREDENTIALS: "..." }; +const token = await verifyIdToken({ idToken, env }); ``` -#### Decoding a `JWT` token +## Optimize cache renewal background tasks + +Pass the optional `waitUntil(promise)` function provided by the target runtime to optimize the way authentication tokens are being renewed in background. For example, using Cloudflare Workers and [Hono.js](https://hono.dev/): ```ts -import { jwt } from "web-auth-library"; +import { Hono } from "hono"; +import { verifyIdToken } from "web-auth-library/google"; -jwt.decode("eyJ0eXAiOiJKV1QiLC..."); -// => { -// header: { alg: "HS256", typ: "JWT" }, -// payload: { iss: "...", aud: "...", iat: ..., exp: ... }, -// signature: "xxx" -// } +const app = new Hono(); -jwt.decode("eyJ0eXAiOiJKV1QiLC...", { header: false, signature: false }); -// => { -// payload: { iss: "...", aud: "...", iat: ..., exp: ... }, -// } +app.get("/", ({ env, executionCtx, json }) => { + const idToken = await verifyIdToken({ + idToken: "...", + waitUntil: executionCtx.waitUntil, + env, + }); + + return json({ ... }); +}) ``` ## Backers 💰 diff --git a/core/error.ts b/core/error.ts new file mode 100644 index 0000000..c9512d6 --- /dev/null +++ b/core/error.ts @@ -0,0 +1,19 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +export class FetchError extends Error { + readonly name: string = "FetchError"; + readonly response: Response; + + constructor( + message: string, + options: { response: Response; cause?: unknown } + ) { + super(message, { cause: options?.cause }); + this.response = options.response; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, Error); + } + } +} diff --git a/google/accessToken.test.ts b/google/accessToken.test.ts new file mode 100644 index 0000000..23795f3 --- /dev/null +++ b/google/accessToken.test.ts @@ -0,0 +1,36 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +import { decodeJwt } from "jose"; +import env from "../test/env.js"; +import { getAccessToken } from "./accessToken.js"; + +test("getAccessToken({ credentials, scope })", async () => { + const accessToken = await getAccessToken({ + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + scope: "https://www.googleapis.com/auth/cloud-platform", + }); + + expect(accessToken?.substring(0, 30)).toEqual( + expect.stringContaining("ya29.c.") + ); +}); + +test("getAccessToken({ credentials, audience })", async () => { + const idToken = await getAccessToken({ + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + audience: "https://example.com", + }); + + expect(idToken?.substring(0, 30)).toEqual( + expect.stringContaining("eyJhbGciOi") + ); + + expect(decodeJwt(idToken)).toEqual( + expect.objectContaining({ + aud: "https://example.com", + email_verified: true, + iss: "https://accounts.google.com", + }) + ); +}); diff --git a/google/accessToken.ts b/google/accessToken.ts new file mode 100644 index 0000000..d4c4178 --- /dev/null +++ b/google/accessToken.ts @@ -0,0 +1,188 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +import { decodeJwt } from "jose"; +import { FetchError } from "../core/error.js"; +import { getCredentials, type Credentials } from "./credentials.js"; +import { createCustomToken } from "./customToken.js"; + +const defaultCache = new Map(); + +/** + * Fetches an access token from Google Cloud API using the provided + * service account credentials. + * + * @throws {FetchError} — If the access token could not be fetched. + */ +export async function getAccessToken(options: Options) { + let credentials: Credentials; + + // Normalize service account credentials + // using env.GOOGLE_CLOUD_CREDENTIALS as a fallback + if (options?.credentials) { + credentials = getCredentials(options.credentials); + } else { + if (!options?.env?.GOOGLE_CLOUD_CREDENTIALS) { + throw new TypeError("Missing credentials"); + } + credentials = getCredentials(options.env.GOOGLE_CLOUD_CREDENTIALS); + } + + // Normalize authentication scope and audience values + const scope = Array.isArray(options.scope) + ? options.scope.join(",") + : options.scope; + const audience = Array.isArray(options.audience) + ? options.audience.join(",") + : options.audience; + + const tokenUrl = credentials.token_uri; + + // Create a cache key that can be used with Cloudflare Cache API + const cacheKeyUrl = new URL(tokenUrl); + cacheKeyUrl.searchParams.set("scope", scope ?? ""); + cacheKeyUrl.searchParams.set("aud", audience ?? ""); + cacheKeyUrl.searchParams.set("key", credentials.private_key_id); + const cacheKey = cacheKeyUrl.toString(); + + // Attempt to retrieve the token from the cache + const cache: Map = options.cache ?? defaultCache; + const cacheValue = cache.get(cacheKey); + let now = Math.floor(Date.now() / 1000); + + if (cacheValue) { + if (cacheValue.created > now - 60 * 60) { + let token = await cacheValue.promise; + + if (token.expires > now) { + return token.token; + } else { + const nextValue = cache.get(cacheKey); + + if (nextValue && nextValue !== cacheValue) { + token = await nextValue.promise; + if (token.expires > now) { + return token.token; + } else { + cache.delete(cacheKey); + } + } + } + } else { + cache.delete(cacheKey); + } + } + + const promise = (async () => { + let res: Response | undefined; + + // Attempt to retrieve the token from Cloudflare cache + // if the code is running in Cloudflare Workers environment + if (self.caches?.default) { + res = await caches.default.match(cacheKey); + } + + if (!res) { + now = Math.floor(Date.now() / 1000); + + // Request a new token from the Google Cloud API + const jwt = await createCustomToken({ + credentials, + scope: options.audience ?? options.scope, + }); + const body = new URLSearchParams(); + body.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"); + body.append("assertion", jwt); + res = await fetch(tokenUrl, { method: "POST", body }); + + if (!res.ok) { + const error = await res + .json<{ error_description?: string }>() + .then((data) => data?.error_description) + .catch(() => undefined); + throw new FetchError(error ?? "Failed to fetch an access token.", { + response: res, + }); + } + + if (self.caches?.default) { + let cacheRes = res.clone(); + cacheRes = new Response(cacheRes.body, cacheRes); + cacheRes.headers.set("Cache-Control", `max-age=3590, public`); + cacheRes.headers.set("Last-Modified", new Date().toUTCString()); + const cachePromise = caches.default.put(cacheKey, cacheRes); + + if (options.waitUntil) { + options.waitUntil(cachePromise); + } + } + } + + const data = await res.json(); + + if ("id_token" in data) { + const claims = decodeJwt(data.id_token); + return { token: data.id_token, expires: claims.exp as number }; + } + + const lastModified = res.headers.get("last-modified"); + const expires = lastModified + ? Math.floor(new Date(lastModified).valueOf() / 1000) + data.expires_in + : now + data.expires_in; + + return { expires, token: data.access_token }; + })(); + + cache.set(cacheKey, { created: now, promise }); + return await promise.then((data) => data.token); +} + +// #region Types + +type Options = { + /** + * Google Cloud service account credentials. + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys + * @default env.GOOGLE_CLOUD_PROJECT + */ + credentials: Credentials | string; + /** + * Authentication scope(s). + */ + scope?: string[] | string; + /** + * Recipients that the ID token should be issued for. + */ + audience?: string[] | string; + env?: { + /** + * Google Cloud project ID. + */ + GOOGLE_CLOUD_PROJECT?: string; + /** + * Google Cloud service account credentials. + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys + */ + GOOGLE_CLOUD_CREDENTIALS: string; + }; + waitUntil?: (promise: Promise) => void; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + cache?: Map; +}; + +type TokenResponse = + | { + access_token: string; + expires_in: number; + token_type: string; + } + | { + id_token: string; + }; + +type CacheValue = { + created: number; + promise: Promise<{ token: string; expires: number }>; +}; + +// #endregion diff --git a/google/auth.test.ts b/google/auth.test.ts deleted file mode 100644 index b9d63a3..0000000 --- a/google/auth.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* SPDX-FileCopyrightText: 2022-present Kriasoft */ -/* SPDX-License-Identifier: MIT */ - -import env from "../test/env.js"; -import { getAuthToken, verifyIdToken } from "./auth.js"; -import { getCredentials } from "./credentials.js"; - -const audience = "https://example.com"; -const credentials = getCredentials(env.GOOGLE_CLOUD_CREDENTIALS); - -test("getAuthToken() => AccessToken", async () => { - const scope = "https://www.googleapis.com/auth/cloud-platform"; - const token = await getAuthToken({ credentials, scope }); - - expect(token).toEqual({ - accessToken: expect.any(String), - expires: expect.any(Number), - scope, - type: "Bearer", - }); - - expect(token.accessToken?.substring(0, 7)).toEqual("ya29.c."); - expect(token.expires).toBeLessThan(Date.now() / 1000 + 3600); - expect(token.expires).toBeGreaterThan(Date.now() / 1000); -}); - -test("getAuthToken() => IdToken", async () => { - const token = await getAuthToken({ credentials, audience }); - - expect(token).toEqual({ - idToken: expect.any(String), - expires: expect.any(Number), - audience, - }); - - expect(token.idToken?.substring(0, 7)).toEqual("eyJhbGc"); - expect(token.expires).toBeLessThan(Date.now() / 1000 + 3600); - expect(token.expires).toBeGreaterThan(Date.now() / 1000); -}); - -test("verifyIdToken(idToken)", async () => { - const token = await getAuthToken({ credentials, audience }); - const payload = await verifyIdToken(token.idToken); - - expect(payload).toEqual({ - iss: "https://accounts.google.com", - aud: audience, - sub: credentials.client_id, - azp: credentials.client_email, - email: credentials.client_email, - email_verified: true, - exp: expect.any(Number), - iat: expect.any(Number), - }); -}); - -test("verifyIdToken(idToken, { audience })", async () => { - const token = await getAuthToken({ credentials, audience }); - const payload = await verifyIdToken(token.idToken, { audience }); - - expect(payload).toEqual({ - iss: "https://accounts.google.com", - aud: audience, - sub: credentials.client_id, - azp: credentials.client_email, - email: credentials.client_email, - email_verified: true, - exp: expect.any(Number), - iat: expect.any(Number), - }); - - // Invalid audience -> undefined - const none = await verifyIdToken(token.idToken, { audience: "n/a" }); - expect(none).toBeUndefined(); -}); diff --git a/google/auth.ts b/google/auth.ts deleted file mode 100644 index c50cc3d..0000000 --- a/google/auth.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* SPDX-FileCopyrightText: 2022-present Kriasoft */ -/* SPDX-License-Identifier: MIT */ - -import QuickLRU from "quick-lru"; -import { Credentials, getCredentials } from "./credentials.js"; -import { algorithm, importKey, sign } from "./crypto.js"; -import { decode, verify, type JwtPayload } from "./jwt.js"; - -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -const cache = new QuickLRU({ - maxSize: 100, - maxAge: 3600000 - 10000, -}); - -/** - * Retrieves an authentication token from OAuth 2.0 authorization server. - * - * @example - * const token = await getAuthToken({ - * credentials: env.GOOGLE_CLOUD_CREDENTIALS, - * scope: "https://www.googleapis.com/auth/cloud-platform" - * ); - * const headers = { Authorization: `Bearer ${token.accessToken}` }; - * const res = await fetch(url, { headers }); - */ -async function getAuthToken(options: AccessTokenOptions): Promise; -async function getAuthToken(options: IdTokenOptions): Promise; -async function getAuthToken( - options: AccessTokenOptions | IdTokenOptions -): Promise { - // Normalize input arguments - const credentials = getCredentials(options.credentials); - const scope = - "scope" in options || !("audience" in options) - ? Array.isArray(options.scope) - ? options.scope.sort().join(" ") - : options.scope - : options.audience; - - // Attempt to retrieve the token from the cache - const keyId = credentials?.private_key_id ?? credentials.client_email; - const cacheKey = Symbol.for(`${keyId}:${scope}`); - let token = cache.get(cacheKey); - - if (!token) { - token = fetchAuthToken(credentials, scope); - cache.set(cacheKey, token); - } - - return token; -} - -export async function fetchAuthToken( - credentials: Credentials, - scope: string | undefined -): Promise { - // JWT token header: {"alg":"RS256","typ":"JWT"} - const header = `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9`; - - // JWT token attributes - const iss = credentials.client_email; - const aud = credentials.token_uri; - const iat = Math.floor(Date.now() / 1000); - const exp = iat + 3600; // 1 hour max - - // JWT token payload - const payload = self - .btoa(JSON.stringify({ iss, aud, scope, exp, iat })) - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); - - // JWT token signature - const signingKey = await importKey(credentials.private_key, ["sign"]); - const signature = await sign(signingKey, `${header}.${payload}`); - - // OAuth 2.0 authorization request - const body = new URLSearchParams(); - body.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"); - body.append("assertion", `${header}.${payload}.${signature}`); - const res = await fetch(credentials.token_uri, { method: "POST", body }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error_description ?? data.error); - } - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const data = await res.json(); - return data.access_token - ? ({ - accessToken: data.access_token.replace(/\.+$/, ""), - type: data.token_type, - scope, - expires: exp, - } as AccessToken) - : ({ - idToken: data.id_token?.replace(/\.+$/, ""), - audience: scope, - expires: exp, - } as IdToken); -} - -async function verifyIdToken( - idToken: string, - options?: VerifyIdTokenOptions -): Promise { - const jwt = decode(idToken); - - const res = await fetch("https://www.googleapis.com/oauth2/v3/certs"); - const data = await res.json<{ keys: JsonWebKey[] }>(); - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const jwk = data.keys.find((key) => (key as any).kid === jwt.header.kid); - const key = await crypto.subtle.importKey( - "jwk", - jwk as JsonWebKey, - algorithm, - false, - ["verify"] - ); - - return await verify(jwt, { key, audience: options?.audience }); -} - -/* ------------------------------------------------------------------------------- * - * TypeScript definitions - * ------------------------------------------------------------------------------- */ - -type AccessTokenOptions = { - credentials: Credentials | string; - scope?: string[] | string; -}; - -type IdTokenOptions = { - credentials: Credentials | string; - audience: string; -}; - -type AccessToken = { - accessToken: string; - type: string; - scope: string; - expires: number; -}; - -type IdToken = { - idToken: string; - audience: string; - expires: number; -}; - -type AuthError = { - error: string; - error_description: string; -}; - -type VerifyIdTokenOptions = { - audience?: string[] | string; -}; - -export { type AccessToken, type IdToken, getAuthToken, verifyIdToken }; diff --git a/google/credentials.test.ts b/google/credentials.test.ts new file mode 100644 index 0000000..cc1e0b2 --- /dev/null +++ b/google/credentials.test.ts @@ -0,0 +1,43 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +import env from "../test/env.js"; +import { + getCredentials, + getPrivateKey, + importPublicKey, +} from "./credentials.js"; + +test("getPrivateKey({ credentials })", async () => { + const privateKey = await getPrivateKey({ + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + }); + + expect(privateKey).toEqual( + expect.objectContaining({ + algorithm: expect.objectContaining({ + hash: { name: "SHA-256" }, + modulusLength: 2048, + name: "RSASSA-PKCS1-v1_5", + }), + }) + ); +}); + +test("importPublicKey({ keyId, certificateURL })", async () => { + const credentials = getCredentials(env.GOOGLE_CLOUD_CREDENTIALS); + const privateKey = await importPublicKey({ + keyId: credentials.private_key_id, + certificateURL: credentials.client_x509_cert_url, + }); + + expect(privateKey).toEqual( + expect.objectContaining({ + algorithm: expect.objectContaining({ + hash: { name: "SHA-256" }, + modulusLength: 2048, + name: "RSASSA-PKCS1-v1_5", + }), + }) + ); +}); diff --git a/google/credentials.ts b/google/credentials.ts index d939cf3..ef925c3 100644 --- a/google/credentials.ts +++ b/google/credentials.ts @@ -1,13 +1,125 @@ /* SPDX-FileCopyrightText: 2022-present Kriasoft */ /* SPDX-License-Identifier: MIT */ -import QuickLRU from "quick-lru"; +import { importPKCS8, importX509, KeyLike } from "jose"; +import { FetchError } from "../core/error.js"; -const cache = new QuickLRU({ maxSize: 100 }); +const inFlight = new Map>(); +const cache = new Map(); /** - * Service account key for Google Cloud Platform (GCP) - * https://cloud.google.com/iam/docs/creating-managing-service-account-keys + * Normalizes Google Cloud Platform (GCP) service account credentials. + */ +export function getCredentials(credentials: Credentials | string): Credentials { + return typeof credentials === "string" || credentials instanceof String + ? Object.freeze(JSON.parse(credentials as string)) + : Object.isFrozen(credentials) + ? credentials + : Object.freeze(credentials); +} + +/** + * Imports a private key from the provided Google Cloud (GCP) + * service account credentials. + */ +export function getPrivateKey(options: { credentials: Credentials | string }) { + const credentials = getCredentials(options.credentials); + return importPKCS8(credentials.private_key, "RS256"); +} + +/** + * Imports a public key for the provided Google Cloud (GCP) + * service account credentials. + * + * @throws {FetchError} - If the X.509 certificate could not be fetched. + */ +export async function importPublicKey(options: { + /** + * Public key ID (kid). + */ + keyId: string; + /** + * The X.509 certificate URL. + * @default "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" + */ + certificateURL?: string; + waitUntil?: (promise: Promise) => void; +}) { + const keyId = options.keyId; + const certificateURL = options.certificateURL ?? "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"; // prettier-ignore + const cacheKey = `${certificateURL}?key=${keyId}`; + const value = cache.get(cacheKey); + const now = Date.now(); + + async function fetchKey() { + // Fetch the public key from Google's servers + const res = await fetch(certificateURL); + + if (!res.ok) { + const error = await res + .json<{ error: { message: string } }>() + .then((data) => data.error.message) + .catch(() => undefined); + throw new FetchError(error ?? "Failed to fetch the public key", { + response: res, + }); + } + + const data = await res.json>(); + const x509 = data[keyId]; + + if (!x509) { + throw new FetchError(`Public key "${keyId}" not found.`, { + response: res, + }); + } + + const key = await importX509(x509, "RS256"); + + // Resolve the expiration time of the key + const maxAge = res.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1]; // prettier-ignore + const expires = Date.now() + Number(maxAge ?? "3600") * 1000; + + // Update the local cache + cache.set(cacheKey, { key, expires }); + inFlight.delete(keyId); + + return key; + } + + // Attempt to read the key from the local cache + if (value) { + if (value.expires > now + 10_000) { + // If the key is about to expire, start a new request in the background + if (value.expires - now < 600_000) { + const promise = fetchKey(); + inFlight.set(cacheKey, promise); + if (options.waitUntil) { + options.waitUntil(promise); + } + } + return value.key; + } else { + cache.delete(cacheKey); + } + } + + // Check if there is an in-flight request for the same key ID + let promise = inFlight.get(cacheKey); + + // If not, start a new request + if (!promise) { + promise = fetchKey(); + inFlight.set(cacheKey, promise); + } + + return await promise; +} + +/** + * Service account credentials for Google Cloud Platform (GCP). + * + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys */ export type Credentials = { type: string; @@ -21,19 +133,3 @@ export type Credentials = { auth_provider_x509_cert_url: string; client_x509_cert_url: string; }; - -export function getCredentials(value: Credentials | string): Credentials { - if (typeof value === "string") { - const cacheKey = Symbol.for(value); - let credentials = cache.get(cacheKey); - - if (!credentials) { - credentials = JSON.parse(value) as Credentials; - cache.set(cacheKey, credentials); - } - - return credentials; - } - - return value; -} diff --git a/google/crypto.ts b/google/crypto.ts deleted file mode 100644 index 399daca..0000000 --- a/google/crypto.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* SPDX-FileCopyrightText: 2022-present Kriasoft */ -/* SPDX-License-Identifier: MIT */ - -import { base64, base64url } from "rfc4648"; - -const algorithm: SubtleCryptoImportKeyAlgorithm = { - name: "RSASSA-PKCS1-v1_5", - hash: { name: "SHA-256" }, -}; - -type KeyUsage = - | "encrypt" - | "decrypt" - | "sign" - | "verify" - | "deriveKey" - | "deriveBits" - | "wrapKey" - | "unwrapKey"; - -/** - * Returns a `CryptoKey` object that you can use in the `Web Crypto API`. - * https://developer.mozilla.org/docs/Web/API/SubtleCrypto - * - * @example - * const signingKey = await importKey( - * env.GOOGLE_CLOUD_CREDENTIALS.private_key, - * ["sign"], - * ); - */ -function importKey(keyData: string, keyUsages: KeyUsage[]): Promise { - return crypto.subtle.importKey( - "pkcs8", - base64.parse( - keyData - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace(/\n/g, "") - ), - algorithm, - false, - keyUsages - ); -} - -/** - * Generates a digital signature. - */ -async function sign(key: CryptoKey, data: string): Promise { - const input = new TextEncoder().encode(data); - const output = await self.crypto.subtle.sign(key.algorithm, key, input); - return base64url.stringify(new Uint8Array(output), { pad: false }); -} - -export { sign, importKey, KeyUsage, algorithm }; diff --git a/google/customToken.test.ts b/google/customToken.test.ts new file mode 100644 index 0000000..85c52de --- /dev/null +++ b/google/customToken.test.ts @@ -0,0 +1,76 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +import { decodeJwt } from "jose"; +import env from "../test/env.js"; +import { createCustomToken } from "./customToken.js"; + +test("createCustomToken({ credentials, scope })", async () => { + const customToken = await createCustomToken({ + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + scope: "https://www.example.com", + }); + + expect(customToken?.substring(0, 30)).toEqual( + expect.stringContaining("eyJhbGciOi") + ); + + expect(decodeJwt(customToken)).toEqual( + expect.objectContaining({ + iss: expect.stringMatching(/\.iam\.gserviceaccount\.com$/), + aud: "https://oauth2.googleapis.com/token", + scope: "https://www.example.com", + iat: expect.any(Number), + exp: expect.any(Number), + }) + ); +}); + +test("createCustomToken({ credentials, scope: scopes })", async () => { + const customToken = await createCustomToken({ + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + scope: ["https://www.example.com", "https://beta.example.com"], + }); + + expect(customToken?.substring(0, 30)).toEqual( + expect.stringContaining("eyJhbGciOi") + ); + + expect(decodeJwt(customToken)).toEqual( + expect.objectContaining({ + iss: expect.stringMatching(/\.iam\.gserviceaccount\.com$/), + aud: "https://oauth2.googleapis.com/token", + scope: "https://www.example.com https://beta.example.com", + iat: expect.any(Number), + exp: expect.any(Number), + }) + ); +}); + +test("createCustomToken({ env, scope })", async () => { + const customToken = await createCustomToken({ + scope: "https://www.googleapis.com/auth/cloud-platform", + env: { GOOGLE_CLOUD_CREDENTIALS: env.GOOGLE_CLOUD_CREDENTIALS }, + }); + + expect(customToken?.substring(0, 30)).toEqual( + expect.stringContaining("eyJhbGciOi") + ); + + expect(decodeJwt(customToken)).toEqual( + expect.objectContaining({ + iss: expect.stringMatching(/\.iam\.gserviceaccount\.com$/), + aud: "https://oauth2.googleapis.com/token", + scope: "https://www.googleapis.com/auth/cloud-platform", + iat: expect.any(Number), + exp: expect.any(Number), + }) + ); +}); + +test("createCustomToken({ env, scope })", async () => { + const promise = createCustomToken({ + scope: "https://www.googleapis.com/auth/cloud-platform", + }); + expect(promise).rejects.toThrow(new TypeError("Missing credentials")); +}); diff --git a/google/customToken.ts b/google/customToken.ts new file mode 100644 index 0000000..a499b88 --- /dev/null +++ b/google/customToken.ts @@ -0,0 +1,90 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +import { SignJWT } from "jose"; +import { + getCredentials, + getPrivateKey, + type Credentials, +} from "./credentials.js"; + +/** + * Generates a custom authentication token (JWT) + * from a Google Cloud (GCP) service account key. + * + * @example + * const customToken = await createCustomToken({ + * credentials: env.GOOGLE_CLOUD_CREDENTIALS, + * scope: "https://www.googleapis.com/auth/cloud-platform", + * }); + * + * @example + * const customToken = await createCustomToken({ + * env: { GOOGLE_CLOUD_CREDENTIALS: "..." }, + * scope: "https://www.example.com", + * }); + */ +export async function createCustomToken(options: { + /** + * Google Cloud service account credentials. + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys + * @default env.GOOGLE_CLOUD_PROJECT + */ + credentials?: Credentials | string; + /** + * Authentication scope. + * @example "https://www.googleapis.com/auth/cloud-platform" + */ + scope?: string | string[]; + /** + * The principal that is the subject of the JWT. + */ + subject?: string; + /** + * The recipient(s) that the JWT is intended for. + */ + audience?: string | string[]; + /** + * Any other JWT clams. + */ + [propName: string]: unknown; + /** + * Alternatively, you can pass credentials via the environment variable. + */ + env?: { + /** + * Google Cloud service account credentials. + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys + */ + GOOGLE_CLOUD_CREDENTIALS: string; + }; +}) { + /* eslint-disable-next-line prefer-const */ + let { credentials, scope, subject, audience, env, ...payload } = options; + + // Normalize credentials using env.GOOGLE_CLOUD_CREDENTIALS as a fallback + if (credentials) { + credentials = getCredentials(credentials); + } else { + if (!env?.GOOGLE_CLOUD_CREDENTIALS) { + throw new TypeError("Missing credentials"); + } + credentials = getCredentials(env.GOOGLE_CLOUD_CREDENTIALS); + } + + // Normalize authentication scope (needs to be a string) + scope = Array.isArray(scope) ? scope.join(" ") : scope; + + // Generate and sign a custom JWT token + const privateKey = await getPrivateKey({ credentials }); + const customToken = await new SignJWT({ scope, ...payload }) + .setIssuer(credentials.client_email) + .setAudience(audience ?? credentials.token_uri) + .setSubject(subject ?? credentials.client_email) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(privateKey); + + return customToken; +} diff --git a/google/idToken.test.ts b/google/idToken.test.ts new file mode 100644 index 0000000..33409b3 --- /dev/null +++ b/google/idToken.test.ts @@ -0,0 +1,57 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +import { decodeJwt } from "jose"; +import env from "../test/env.js"; +import { getIdToken, verifyIdToken } from "./idToken.js"; + +test("getIdToken({ uid, apiKey, projectId, credentials })", async () => { + const token = await getIdToken({ + uid: "temp", + claims: { foo: "bar" }, + apiKey: env.FIREBASE_API_KEY, + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + }); + + expect(token).toEqual( + expect.objectContaining({ + kind: "identitytoolkit#VerifyCustomTokenResponse", + idToken: expect.stringMatching(/^eyJhbGciOiJSUzI1NiIs/), + refreshToken: expect.any(String), + expiresIn: "3600", + isNewUser: expect.any(Boolean), + }) + ); + + expect(decodeJwt(token.idToken)).toEqual( + expect.objectContaining({ + sub: "temp", + user_id: "temp", + aud: env.GOOGLE_CLOUD_PROJECT, + iss: `https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`, + iat: expect.any(Number), + exp: expect.any(Number), + auth_time: expect.any(Number), + }) + ); +}); + +test("verifyIdToken({ idToken })", async () => { + const { idToken } = await getIdToken({ + uid: "temp", + apiKey: env.FIREBASE_API_KEY, + credentials: env.GOOGLE_CLOUD_CREDENTIALS, + }); + const token = await verifyIdToken({ idToken, env }); + + expect(token).toEqual( + expect.objectContaining({ + aud: env.GOOGLE_CLOUD_PROJECT, + iss: `https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`, + sub: "temp", + user_id: "temp", + iat: expect.any(Number), + exp: expect.any(Number), + }) + ); +}); diff --git a/google/idToken.ts b/google/idToken.ts new file mode 100644 index 0000000..59a9ad0 --- /dev/null +++ b/google/idToken.ts @@ -0,0 +1,243 @@ +/* SPDX-FileCopyrightText: 2022-present Kriasoft */ +/* SPDX-License-Identifier: MIT */ + +import { decodeProtectedHeader, errors, jwtVerify } from "jose"; +import { FetchError } from "../core/error.js"; +import { Credentials, getCredentials, importPublicKey } from "./credentials.js"; +import { createCustomToken } from "./customToken.js"; + +/** + * Creates a User ID token using Google Cloud service account credentials. + */ +export async function getIdToken(options: { + /** + * User ID. + */ + uid: string; + /** + * Additional user claims. + */ + claims?: Record; + /** + * Google Cloud API key. + * @see https://console.cloud.google.com/apis/credentials + * @default env.FIREBASE_API_KEY + */ + apiKey?: string; + /** + * Google Cloud project ID. + * @default env.GOOGLE_CLOUD_PROJECT; + */ + projectId?: string; + /** + * Google Cloud service account credentials. + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys + * @default env.GOOGLE_CLOUD_PROJECT + */ + credentials?: Credentials | string; + /** + * Alternatively, you can pass credentials via the environment variable. + */ + env?: { + /** + * Google Cloud API key. + * @see https://console.cloud.google.com/apis/credentials + */ + FIREBASE_API_KEY: string; + /** + * Google Cloud project ID. + */ + GOOGLE_CLOUD_PROJECT: string; + /** + * Google Cloud service account credentials. + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys + */ + GOOGLE_CLOUD_CREDENTIALS: string; + }; +}) { + const uid = options?.uid; + + if (!uid) { + throw new TypeError("Missing uid"); + } + + let apiKey = options?.apiKey; + + if (!apiKey) { + if (options?.env?.FIREBASE_API_KEY) { + apiKey = options.env.FIREBASE_API_KEY; + } else { + throw new TypeError("Missing apiKey"); + } + } + + let credentials = options?.credentials; + + if (credentials) { + credentials = getCredentials(credentials); + } else { + if (options?.env?.GOOGLE_CLOUD_CREDENTIALS) { + credentials = getCredentials(options.env.GOOGLE_CLOUD_CREDENTIALS); + } else { + throw new TypeError("Missing credentials"); + } + } + + let projectId = options?.projectId; + + if (!projectId && options?.env?.GOOGLE_CLOUD_PROJECT) { + projectId = options.env.GOOGLE_CLOUD_PROJECT; + } + + if (!projectId) { + projectId = credentials.project_id; + } + + if (!projectId) { + throw new TypeError("Missing projectId"); + } + + const customToken = await createCustomToken({ + ...options.claims, + credentials, + audience: + "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit", + uid: options.uid, + }); + + const url = new URL("https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken"); // prettier-ignore + url.searchParams.set("key", apiKey); + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: customToken, + returnSecureToken: true, + }), + }); + + if (!res.ok) { + const message = await res + .json<{ error: { message: string } }>() + .then((body) => body?.error?.message) + .catch(() => undefined); + throw new FetchError(message ?? "Failed to verify custom token", { + response: res, + }); + } + + return await res.json(); +} + +/** + * Verifies the authenticity of an ID token issued by Google. + * + * @example + * const token = await verifyIdToken({ + * idToken: "eyJhbGciOiJSUzI1NiIsImtpZC...yXQ" + * projectId: "my-project" + * waitUntil: ctx.waitUntil, + * }); + * + * @example + * const token = await verifyIdToken({ + * idToken: "eyJhbGciOiJSUzI1NiIsImtpZC...yXQ" + * waitUntil: ctx.waitUntil, + * env: { GOOGLE_CLOUD_PROJECT: "my-project" } + * }); + * + * @see https://firebase.google.com/docs/auth/admin/verify-id-tokens + * + * @throws {TypeError} if the ID token is missing + * @throws {FetchError} if unable to fetch the public key + * @throws {JWTClaimValidationFailed} if the token is invalid + * @throws {JWTExpired} if the token has expired + */ +export async function verifyIdToken(options: { + /** + * The ID token to verify. + */ + idToken: string; + /** + * Google Cloud project ID. Set to `null` to disable the check. + * @default env.GOOGLE_CLOUD_PROJECT + */ + projectId?: string | null; + /** + * Alternatively, you can provide the following environment variables: + */ + env?: { + /** + * Google Cloud project ID. + */ + GOOGLE_CLOUD_PROJECT?: string; + /** + * Google Cloud service account credentials. + * @see https://cloud.google.com/iam/docs/creating-managing-service-account-keys + */ + GOOGLE_CLOUD_CREDENTIALS?: string; + }; + waitUntil?: (promise: Promise) => Promise; +}) { + if (!options?.idToken) { + throw new TypeError(`Missing "idToken"`); + } + + // #region Get the Google Cloud project ID + // using environment variables as a fallback + let projectId = options?.projectId; + + if (projectId === undefined) { + projectId = options?.env?.GOOGLE_CLOUD_PROJECT; + } + + if (projectId === undefined && options?.env?.GOOGLE_CLOUD_CREDENTIALS) { + const credentials = getCredentials(options.env.GOOGLE_CLOUD_CREDENTIALS); + projectId = credentials?.project_id; + } + + if (projectId === undefined) { + throw new TypeError(`Missing "projectId"`); + } + // #endregion + + // Import the public key from the Google Cloud project + const header = decodeProtectedHeader(options.idToken); + const now = Math.floor(Date.now() / 1000); + const key = await importPublicKey({ + keyId: header.kid as string, + certificateURL: "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com", // prettier-ignore + waitUntil: options.waitUntil, + }); + + const { payload } = await jwtVerify(options.idToken, key, { + audience: projectId == null ? undefined : projectId, + issuer: + projectId == null + ? undefined + : `https://securetoken.google.com/${projectId}`, + maxTokenAge: "1h", + }); + + if (!payload.sub) { + throw new errors.JWTClaimValidationFailed(`Missing "sub" claim`, "sub"); + } + + if (typeof payload.auth_time === "number" && payload.auth_time > now) { + throw new errors.JWTClaimValidationFailed( + `Unexpected "auth_time" claim value`, + "auth_time" + ); + } + + return payload; +} + +type VerifyCustomTokenResponse = { + kind: "identitytoolkit#VerifyCustomTokenResponse"; + idToken: string; + refreshToken: string; + expiresIn: string; + isNewUser: boolean; +}; diff --git a/google/index.ts b/google/index.ts index 70e2285..1816552 100644 --- a/google/index.ts +++ b/google/index.ts @@ -1,7 +1,7 @@ /* SPDX-FileCopyrightText: 2022-present Kriasoft */ /* SPDX-License-Identifier: MIT */ -export { getAuthToken, type AccessToken, type IdToken } from "./auth.js"; -export { getCredentials, type Credentials } from "./credentials.js"; -export { importKey, sign, type KeyUsage } from "./crypto.js"; -export * as jwt from "./jwt.js"; +export * from "./accessToken.js"; +export * from "./credentials.js"; +export * from "./customToken.js"; +export * from "./idToken.js"; diff --git a/google/jwt.ts b/google/jwt.ts deleted file mode 100644 index ce0f2f9..0000000 --- a/google/jwt.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* SPDX-FileCopyrightText: 2022-present Kriasoft */ -/* SPDX-License-Identifier: MIT */ - -import { - decode as decodeCore, - verify as verifyCore, - type VerifyOptions, -} from "../core/jwt.js"; -export { decode, verify, type JwtHeader, type JwtPayload, type Jwt }; - -/** - * Identifies which algorithm is used to generate the signature. - */ -interface JwtHeader { - /** Token type */ - typ: string; - /** Message authentication code algorithm */ - alg: string; - /** Key ID */ - kid: string; -} - -/** - * Contains a set of claims. - */ -interface JwtPayload { - /** Issuer */ - iss: string; - /** Subject */ - sub: string; - /** Audience */ - aud: string; - /** Authorized party */ - azp: string; - /** Expiration time */ - exp: number; - /** Issued at */ - iat: number; - - email: string; - email_verified: boolean; -} - -type Jwt = { - header: JwtHeader; - payload: JwtPayload; - data: string; - signature: string; -}; - -const decode = decodeCore as (token: string) => Jwt; -const verify = verifyCore as ( - token: Jwt | string, - options: VerifyOptions -) => Promise; diff --git a/package.json b/package.json index 4df85c1..c900959 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,22 @@ "auth", "authentication", "authorization", + "bearer", "browser", + "bun", + "cloudflare-workers", + "cloudflare", "crypto", "decrypt", + "deno", "encrypt", + "hono", "jsonwebtoken", "jwk", "jwt", "keys", - "oauth 2.0", "oauth", + "oauth2", "sign", "subtlecrypto", "token", @@ -55,11 +61,11 @@ "exports": { ".": "./dist/index.js", "./jwt": "./dist/core/jwt.js", - "./google": "./dist/google/index.js", - "./google/auth": "./dist/google/auth.js", - "./google/credentials": "./dist/google/credentials.js", - "./google/crypto": "./dist/google/crypto.js", - "./google/jwt": "./dist/google/jwt.js", + "./google": { + "types": "./dist/google/index.d.ts", + "import": "./dist/google/index.js", + "default": "./dist/google/index.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -68,7 +74,7 @@ "build": "rm -rf ./dist && yarn tsc" }, "dependencies": { - "quick-lru": "^6.1.1", + "jose": ">= 4.12.0 < 5.0.0", "rfc4648": "^1.5.2" }, "devDependencies": { @@ -78,8 +84,8 @@ "@babel/preset-typescript": "^7.21.0", "@cloudflare/workers-types": "^4.20230221.0", "@types/jest": "^29.4.0", - "@typescript-eslint/eslint-plugin": "^5.53.0", - "@typescript-eslint/parser": "^5.53.0", + "@typescript-eslint/eslint-plugin": "^5.54.0", + "@typescript-eslint/parser": "^5.54.0", "babel-jest": "^29.4.3", "babel-plugin-replace-import-extension": "^1.1.3", "dotenv": "^16.0.3", diff --git a/test/env.ts b/test/env.ts index 75b4775..77b761a 100644 --- a/test/env.ts +++ b/test/env.ts @@ -4,5 +4,7 @@ import { cleanEnv, str } from "envalid"; export default cleanEnv(process.env, { + GOOGLE_CLOUD_PROJECT: str(), GOOGLE_CLOUD_CREDENTIALS: str(), + FIREBASE_API_KEY: str(), }); diff --git a/test/test.env b/test/test.env index f358c22..44edcec 100644 --- a/test/test.env +++ b/test/test.env @@ -3,4 +3,6 @@ # Google Cloud # https://cloud.google.com/iam/docs/creating-managing-service-account-keys +GOOGLE_CLOUD_PROJECT=kriasoft GOOGLE_CLOUD_CREDENTIALS={"type":"service_account","project_id":"example","private_key_id":"25809e59963e2cbbe616cc9dd2feedcc4b620da5","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC407XFlKSwvNbQ\nVnmgZK4zkUOgGARwXpdvTZXvPwCaM1jA+g6WxAv5sIqj9JVEQnwBmre+S5uqJ7tD\nfLlMei7Vbb1FuVLlWfYu6STAkuzU5JC13b+AwRqYDNNtIeNngl9FEnfofHmoCaox\n8r4UAALr2/R3KPoUUGq5Reb70XUwuw2cyLDUiQEwUoLm3HUcIbHFAwIJ/W+s9ghy\n9FBBPAYVUMLudutlb8yIna0qW84rreqBgSGFCmU/R/zeNcNbWIasWBkqgjV661ci\nqXViBuOC9BWeJ0cGIa2JV/IeHrjqROsWo8h2ev1NWjZ7s1LP33ylgGInMgxuDvRI\nfUh8z7nHAgMBAAECggEAMSLfjUKCMhZSCZsjxJdflIgG8XXRIEqVedqnhK48K8KA\n0vTnkf9Wq6/ae9IXKMmADDEkriuNm8PqTfvHi2RkNQtyqSmmtyCeiUQkKConWkXV\njvP/6Gvt9QRb5QSAX1FSoJtTU3RcJ2dCXvsIu2pxXGDichdrvKDQbqb9zG6X+Dce\nmO7lu/xBStR/Q4aD2nC6TF799gSPR3yI+XyfHdBzBaN35RqVfIONByy4VH2ArIYt\npxXdsevDR4HYxV7hciSIehXTDL0x9+zUXRzFslUGY/E1c2fTSJOb7IwAEyeMzB0t\n/6i+aIZrMdVPcwxmVVG90Y0CZE+OGoQq1nm6DjWdqQKBgQDjAFiKFYP4KXijPzfm\n/1Idvht5Ol2Tr2+BQ7trkdkLbLrxcmIyUzVlBMU82uQVhTqCXZAy8eYuzsXMHp9J\nibgg4YqtdX+wIYaO8tELhAEtO3tr/nMsVe8su5RQlhiCTCO4HR0OyOMhnnudYwUn\ncO+rbX3cixOMxADMS/FTWQ/r7QKBgQDQcCF5WmpThSg3boxVy0jJVSB6/8+BTZdy\nqF+wcb2BqJe8OtnHnbXZTakau+f13Jy4ttJYB/moe/trjCbl8ihcDmMGbuAg3Eir\nax2mm4ZetUPVrgZ+Y/wwjA6lO1THWdI8cIQJE4mNEhXeqd3FFK0X445FHdiroBbd\nNAji4H0OAwKBgA9vLpX08IwnBbTTz5E9OvAaxPNxLHumKga3/D5MJF3Kfst744FY\ndwDvWhnRKEDuVhQXGH7eQ7BbDsfaLSpq2sIhk7RHkO8A2I1PpTcLOqlAqhulqV8S\nWLjJ6EOycOgrFSKnmBoxPoBCrlT9LpSH8UPOpgggzKt9iDBb2YS5QYPhAoGAVmJy\nbRHcyRqBjV+ih5gFZXODT5afUC5xGtLPPZgV+xt9L0SQp1skV5gJAoxn2QyCY0dZ\nq6Q6gupHS848/MW8llJcFflzqArDj0+IbVk9ehjTsUY7aLxVc2VIWJBbVXdTWzsi\nbYSMWEvrhmmOALTN+/2SI/D3sEFb2HdNS4HQMjMCgYBuEBqRQNMofnzDgrKN8AwQ\n4/SAVq4ER1lqs4QTixIvFkfeGAH/AzcfsvwYjzi6Pwu8TMxvrmQhrDyvjsrfB204\nU+JhsYJje/wBTWucVEuaWZ/H5gUiFnMV8SytmvfMszRhWX+sG3ucQOLC4J9Enbh5\nsDSQTShw4U1Wd03w55TrZg==\n-----END PRIVATE KEY-----\n","client_email":"example@example.iam.gserviceaccount.com","client_id":"118360562778253889493","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/example%40example.iam.gserviceaccount.com"} +FIREBASE_API_KEY=xxxxx diff --git a/yarn.lock b/yarn.lock index f117ec9..6b16d2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2112,9 +2112,9 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 18.14.1 - resolution: "@types/node@npm:18.14.1" - checksum: b91f3b982e51b2560373086d70b43b2938f28e8ec63ad3fb77f742050a54ac1205b92c16e5d96514445e74e3e190e495f61f0acbc90da96548b2ad4973e93a98 + version: 18.14.2 + resolution: "@types/node@npm:18.14.2" + checksum: 129305df2c8e761c22cfa7a88ab780577864c8fc53a5b70cabbe4dd595e4b38b211b73a4380d0a18bf5da3634081bfa554cccf52dfd5e228e7a27de12c857aaf languageName: node linkType: hard @@ -2155,13 +2155,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/eslint-plugin@npm:5.53.0" +"@typescript-eslint/eslint-plugin@npm:^5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/eslint-plugin@npm:5.54.0" dependencies: - "@typescript-eslint/scope-manager": "npm:5.53.0" - "@typescript-eslint/type-utils": "npm:5.53.0" - "@typescript-eslint/utils": "npm:5.53.0" + "@typescript-eslint/scope-manager": "npm:5.54.0" + "@typescript-eslint/type-utils": "npm:5.54.0" + "@typescript-eslint/utils": "npm:5.54.0" debug: "npm:^4.3.4" grapheme-splitter: "npm:^1.0.4" ignore: "npm:^5.2.0" @@ -2175,43 +2175,43 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: edfda0000f08b19eddc97465405975cd5087107244c2d432a74e341d7c919b9c37d8bb4e94d86129ac53e5f3c1369d985ba199b87bf16dec8bc2bafdee5f1559 + checksum: 0ab847098133b4f04583ccf3c65ef8c4b8de9616a2a4534c16b9d96f2f5a05f25e0ac0cd510216e3e6e10e490172655619b46b09d9912ab44ac5baa441678d56 languageName: node linkType: hard -"@typescript-eslint/parser@npm:^5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/parser@npm:5.53.0" +"@typescript-eslint/parser@npm:^5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/parser@npm:5.54.0" dependencies: - "@typescript-eslint/scope-manager": "npm:5.53.0" - "@typescript-eslint/types": "npm:5.53.0" - "@typescript-eslint/typescript-estree": "npm:5.53.0" + "@typescript-eslint/scope-manager": "npm:5.54.0" + "@typescript-eslint/types": "npm:5.54.0" + "@typescript-eslint/typescript-estree": "npm:5.54.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - checksum: e0e0b2202b8b48e0b92ab1a06973dcbf355bdc9b44257f7965a9adaf3d20fd70e365eb4081bfbe62c4f71c629b2e94d8ddc8ba0143e3db5a98ef125b292d6fe9 + checksum: 2d996fd4ffdcbd510e3a12db73ec0f83a3db892e8fac23cd1103a3d03b5e0c49edd552e99eb1004640120870fdc3c60d428442e6500c133920db1774477268fe languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/scope-manager@npm:5.53.0" +"@typescript-eslint/scope-manager@npm:5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/scope-manager@npm:5.54.0" dependencies: - "@typescript-eslint/types": "npm:5.53.0" - "@typescript-eslint/visitor-keys": "npm:5.53.0" - checksum: 1306242cb8cbfade1d918d566de085508b92ee014eb4d518c2f04ea153b8178edaf7ac9090cc18eafadfd91240c54be068d437303e311a91c46c03c6e8836cb1 + "@typescript-eslint/types": "npm:5.54.0" + "@typescript-eslint/visitor-keys": "npm:5.54.0" + checksum: 190a9393591ee44fbd74e7e90b84f025575df12ecb7a58fde52e0646f95e09d985d5bfd937d7c3f1c63929048d5c3e4f37f04fe006015ad1007d729c979bd4af languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/type-utils@npm:5.53.0" +"@typescript-eslint/type-utils@npm:5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/type-utils@npm:5.54.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:5.53.0" - "@typescript-eslint/utils": "npm:5.53.0" + "@typescript-eslint/typescript-estree": "npm:5.54.0" + "@typescript-eslint/utils": "npm:5.54.0" debug: "npm:^4.3.4" tsutils: "npm:^3.21.0" peerDependencies: @@ -2219,23 +2219,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: df022881421bf8010f041880786dd0284200ccb5b5d940d028296a539174e2b44dacdb2db5d22ba14073400cd518284f20f92187309d27065847d68a6c1791a8 + checksum: 2234eaf33364b49d6cc6792d2d3107b2e3e165d1a5348f27d10b0f7d5a615fb7cf406f8d8972a00a02e7a23dec7f0efeb919dc4dfff1fa05d822c703fd86bbc2 languageName: node linkType: hard -"@typescript-eslint/types@npm:5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/types@npm:5.53.0" - checksum: 7efa45c831e9c68fcbb394426d948bbc5f97afd04e3d978f4567eb9eaa9fe722abccc9118098accf17a868190bdd7fe1e0b27fb15b476e5cb7df1eb559267417 +"@typescript-eslint/types@npm:5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/types@npm:5.54.0" + checksum: cb12ff68aed7833bec7bccc0a5ac232be483ae3072fd6aae7d64657cc02025e18a3a9ce0110a716bd5b4876146be0ffd41f05d9a440aef99757fc83ed9bda957 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/typescript-estree@npm:5.53.0" +"@typescript-eslint/typescript-estree@npm:5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/typescript-estree@npm:5.54.0" dependencies: - "@typescript-eslint/types": "npm:5.53.0" - "@typescript-eslint/visitor-keys": "npm:5.53.0" + "@typescript-eslint/types": "npm:5.54.0" + "@typescript-eslint/visitor-keys": "npm:5.54.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2244,35 +2244,35 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: dc7341ce17a332aecdefd9642720b89de12e79a2339e729573af8597014ac3b19456c8261a55344a40a27ac5e60631f9e754d37336fc88f171d23bed9b51168a + checksum: 607932df5bf787114b3437e9165bdf57349018567e2f7b2b8789ada51efdfc38f267bdf2c51a3e748ce75565e125feb5745a073c8324d10ab84176d3b29d4282 languageName: node linkType: hard -"@typescript-eslint/utils@npm:5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/utils@npm:5.53.0" +"@typescript-eslint/utils@npm:5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/utils@npm:5.54.0" dependencies: "@types/json-schema": "npm:^7.0.9" "@types/semver": "npm:^7.3.12" - "@typescript-eslint/scope-manager": "npm:5.53.0" - "@typescript-eslint/types": "npm:5.53.0" - "@typescript-eslint/typescript-estree": "npm:5.53.0" + "@typescript-eslint/scope-manager": "npm:5.54.0" + "@typescript-eslint/types": "npm:5.54.0" + "@typescript-eslint/typescript-estree": "npm:5.54.0" eslint-scope: "npm:^5.1.1" eslint-utils: "npm:^3.0.0" semver: "npm:^7.3.7" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 408eda00e3b7e9ada89a82a30c8c822b2b6e88fe81bcf1bce88167f4b5792f0c8af85b887ea801fda2580f61829c22598b6edd000f5ad6a21da25720a93a8659 + checksum: ee7b9579db1667696cd183ab55092fc3dc4bb6c6d7ca76861a7d9bc62646d10b998d8dc8c94916162ae22ca62eded0e1458dc7f9085a77bf4799fe12827815a5 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:5.53.0": - version: 5.53.0 - resolution: "@typescript-eslint/visitor-keys@npm:5.53.0" +"@typescript-eslint/visitor-keys@npm:5.54.0": + version: 5.54.0 + resolution: "@typescript-eslint/visitor-keys@npm:5.54.0" dependencies: - "@typescript-eslint/types": "npm:5.53.0" + "@typescript-eslint/types": "npm:5.54.0" eslint-visitor-keys: "npm:^3.3.0" - checksum: bf2a2fd34988ea6b427bc786e9a149187cc35b07326d666b8370b6356dbe897bfa138804e9df9a9762475f753b2741f6c62fbcb5b5474d7488ab9b451dc2e25c + checksum: 340a3710839d9e57170ebc5de4c4bc3e2cf0c616c531d7c243fbca9da3877fd8e3a4a318cc7af5276b1e5d7e7f1c092656a2e1ec8720288692b5d39ade4c3505 languageName: node linkType: hard @@ -2874,11 +2874,11 @@ __metadata: linkType: hard "core-js-compat@npm:^3.25.1": - version: 3.28.0 - resolution: "core-js-compat@npm:3.28.0" + version: 3.29.0 + resolution: "core-js-compat@npm:3.29.0" dependencies: browserslist: "npm:^4.21.5" - checksum: 490c6b9d3b8be80b7dc1706bed34f3e57083476c3fe1f8d091ecd3250942649c1bd3eda5457f1d756487cd1b3efb7ec6e632edf6075b4108d11a804df5aaf57c + checksum: 33d0fd56234e1932ba3c380dc97c4e2bc14ef2779c43c05cfbe66ff488095eb0a3774ccaf6e684835f58cf45edc03d721311152ffafb0468635e1993d72818c4 languageName: node linkType: hard @@ -2987,9 +2987,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.4.284": - version: 1.4.311 - resolution: "electron-to-chromium@npm:1.4.311" - checksum: 81b6b7602bdf39d4ba949533549a3040050b8dc14d9fd43bb79362feb13fbef945087fbe5bbc714a11330b0dd43924c319f3c4195a07bb53ba2ccd815d8d1ade + version: 1.4.312 + resolution: "electron-to-chromium@npm:1.4.312" + checksum: b852284945b796dc3c1faa73b063f653fc390c49150d0dfc7ec40cfae7933f5b8e5e854a87b13de2aba5e66134d2072ac769011cf2124fd206be2d7c8ec35993 languageName: node linkType: hard @@ -4362,6 +4362,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:>= 4.12.0 < 5.0.0": + version: 4.13.0 + resolution: "jose@npm:4.13.0" + checksum: b50849fac40ba42fe0a95e39c316627877122bab7cade7cf7d34ffe115d100f07f46428c4335b716fbcc1ee9c331acecea2c7e14894c159202cd0a3349543546 + languageName: node + linkType: hard + "js-sdsl@npm:^4.1.4": version: 4.3.0 resolution: "js-sdsl@npm:4.3.0" @@ -5133,13 +5140,6 @@ __metadata: languageName: node linkType: hard -"quick-lru@npm:^6.1.1": - version: 6.1.1 - resolution: "quick-lru@npm:6.1.1" - checksum: 905b489bd7c561438ea643cbee0cc14a4e8716781a522be37f530590903c215b2c314fcfa249290f293395d53d4d1207c922d6693c33a90d1ee247c8f1d94502 - languageName: node - linkType: hard - "react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0" @@ -5878,8 +5878,8 @@ __metadata: "@babel/preset-typescript": "npm:^7.21.0" "@cloudflare/workers-types": "npm:^4.20230221.0" "@types/jest": "npm:^29.4.0" - "@typescript-eslint/eslint-plugin": "npm:^5.53.0" - "@typescript-eslint/parser": "npm:^5.53.0" + "@typescript-eslint/eslint-plugin": "npm:^5.54.0" + "@typescript-eslint/parser": "npm:^5.54.0" babel-jest: "npm:^29.4.3" babel-plugin-replace-import-extension: "npm:^1.1.3" dotenv: "npm:^16.0.3" @@ -5887,8 +5887,8 @@ __metadata: eslint: "npm:^8.35.0" jest: "npm:^29.4.3" jest-environment-miniflare: "npm:^2.12.1" + jose: "npm:>= 4.12.0 < 5.0.0" prettier: "npm:^2.8.4" - quick-lru: "npm:^6.1.1" rfc4648: "npm:^1.5.2" typescript: "npm:^4.9.5" languageName: unknown