From b7427cb587084f97e91e29391977fd9d7176e6c2 Mon Sep 17 00:00:00 2001 From: Nixieboluo Date: Tue, 7 Jul 2026 16:50:55 +0800 Subject: [PATCH] feat(desktop): add Google One Tap auth --- frontend/desktop/data/config.yaml | 1 + .../desktop-frontend/templates/configmap.yaml | 4 +- .../charts/desktop-frontend/values.yaml | 1 + frontend/desktop/package.json | 11 +- .../backend/auth/google-onetap.api.test.ts | 243 ++++++++++++++++++ .../src/pages/api/auth/google/onetap.ts | 166 ++++++++++++ .../src/pages/api/platform/getAuthConfig.ts | 3 +- frontend/desktop/src/types/system.ts | 4 +- frontend/pnpm-lock.yaml | 89 ++++++- 9 files changed, 513 insertions(+), 9 deletions(-) create mode 100644 frontend/desktop/src/__tests__/unit/backend/auth/google-onetap.api.test.ts create mode 100644 frontend/desktop/src/pages/api/auth/google/onetap.ts diff --git a/frontend/desktop/data/config.yaml b/frontend/desktop/data/config.yaml index 0a3ca0a33843..d22e54fd6c6e 100644 --- a/frontend/desktop/data/config.yaml +++ b/frontend/desktop/data/config.yaml @@ -118,6 +118,7 @@ desktop: proxyAddress: "" clientID: "" clientSecret: "" + oneTapOrigins: [] oauth2: enabled: false pkce: false diff --git a/frontend/desktop/deploy/charts/desktop-frontend/templates/configmap.yaml b/frontend/desktop/deploy/charts/desktop-frontend/templates/configmap.yaml index b5c4f8ed623e..95c3b39d70e5 100644 --- a/frontend/desktop/deploy/charts/desktop-frontend/templates/configmap.yaml +++ b/frontend/desktop/deploy/charts/desktop-frontend/templates/configmap.yaml @@ -131,6 +131,8 @@ data: proxyAddress: "{{ .Values.desktopConfig.googleProxyAddress }}" clientID: "{{ .Values.desktopConfig.googleClientId }}" clientSecret: "{{ .Values.desktopConfig.googleClientSecret }}" + oneTapOrigins: +{{ toYaml .Values.desktopConfig.googleOneTapOrigins | indent 14 }} oauth2: enabled: {{ .Values.desktopConfig.oauth2Enabled }} pkce: {{ .Values.desktopConfig.oauth2Pkce }} @@ -189,4 +191,4 @@ data: ssl: {{ .Values.desktopConfig.realNameOSSSSL }} port: {{ .Values.desktopConfig.realNameOSSPort }} realNameBucket: "{{ .Values.desktopConfig.realNameOSSRealNameBucket }}" - enterpriseRealNameBucket: "{{ .Values.desktopConfig.realNameOSSEnterpriseRealNameBucket }}" \ No newline at end of file + enterpriseRealNameBucket: "{{ .Values.desktopConfig.realNameOSSEnterpriseRealNameBucket }}" diff --git a/frontend/desktop/deploy/charts/desktop-frontend/values.yaml b/frontend/desktop/deploy/charts/desktop-frontend/values.yaml index fb59d8ae6c3c..5cef75d99f7d 100644 --- a/frontend/desktop/deploy/charts/desktop-frontend/values.yaml +++ b/frontend/desktop/deploy/charts/desktop-frontend/values.yaml @@ -57,6 +57,7 @@ desktopConfig: allowedOrigins: - "costcenter" - "license" + googleOneTapOrigins: [] # Database connections (auto-configured from sealos-config) databaseMongodbURI: "" # Auto-fetched from sealos-config.databaseMongodbURI diff --git a/frontend/desktop/package.json b/frontend/desktop/package.json index d91e0056012d..a39e61094656 100644 --- a/frontend/desktop/package.json +++ b/frontend/desktop/package.json @@ -59,6 +59,7 @@ "eslint": "8.38.0", "eslint-config-next": "13.3.0", "framer-motion": "^10.16.4", + "google-auth-library": "^10.9.0", "i18next": "^23.11.5", "immer": "^10.0.2", "js-cookie": "^3.0.5", @@ -97,6 +98,7 @@ "zustand": "^4.4.1" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@testing-library/react": "^14.0.0", "@types/js-cookie": "^3.0.4", "@types/js-yaml": "^4.0.6", @@ -110,13 +112,12 @@ "@types/react-dom": "18.3.7", "@types/umami-browser": "^2.3.2", "@types/uuid": "^9.0.4", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser": "4.0.15", + "@vitest/browser-playwright": "4.0.15", "dotenv-cli": "^7.3.0", "prettier": "^2.8.8", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^4.0.12", - "@playwright/test": "^1.58.1", - "@vitest/browser": "4.0.15", - "@vitest/browser-playwright": "4.0.15", - "@vitejs/plugin-react": "^5.1.1" + "vitest": "^4.0.12" } } diff --git a/frontend/desktop/src/__tests__/unit/backend/auth/google-onetap.api.test.ts b/frontend/desktop/src/__tests__/unit/backend/auth/google-onetap.api.test.ts new file mode 100644 index 000000000000..e5704411eaad --- /dev/null +++ b/frontend/desktop/src/__tests__/unit/backend/auth/google-onetap.api.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const verifyIdToken = vi.fn(); +const getPayload = vi.fn(); + +vi.mock('google-auth-library', () => ({ + OAuth2Client: vi.fn(function () { + return { + verifyIdToken + }; + }) +})); + +vi.mock('@/services/enable', () => ({ + enableGoogle: vi.fn(() => true) +})); + +vi.mock('@/services/backend/persistImage', () => ({ + persistImage: vi.fn(() => Promise.resolve('persisted-avatar')) +})); + +vi.mock('@/services/backend/globalAuth', () => ({ + getGlobalToken: vi.fn() +})); + +import handler from '@/pages/api/auth/google/onetap'; +import { persistImage } from '@/services/backend/persistImage'; +import { getGlobalToken } from '@/services/backend/globalAuth'; + +const mockPersistImage = vi.mocked(persistImage); +const mockGetGlobalToken = vi.mocked(getGlobalToken); + +const createMockRes = () => { + const res: any = { + headers: {}, + statusCode: 200, + body: undefined, + setHeader: vi.fn((name: string, value: string | string[]) => { + res.headers[name] = value; + }), + status: vi.fn((code: number) => { + res.statusCode = code; + return res; + }), + json: vi.fn((payload: unknown) => { + res.body = payload; + return res; + }), + end: vi.fn(() => res) + }; + return res; +}; + +const setAppConfig = (oneTapOrigins = ['https://www.example.com']) => { + (global as any).AppConfig = { + desktop: { + auth: { + idp: { + google: { + clientID: 'google-client-id', + oneTapOrigins + } + } + } + } + }; +}; + +describe('google onetap api handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + setAppConfig(); + getPayload.mockReturnValue({ + sub: 'google-sub', + name: 'Ada Lovelace', + picture: 'https://example.com/avatar.png', + email: 'ada@example.com', + email_verified: true + }); + verifyIdToken.mockResolvedValue({ getPayload }); + mockGetGlobalToken.mockResolvedValue({ + token: 'global-token', + user: { + name: 'Ada', + avatar: 'persisted-avatar', + userUid: 'user-uid' + }, + needInit: false + }); + }); + + it('allows preflight from configured One Tap origin', async () => { + const req: any = { + method: 'OPTIONS', + headers: { + origin: 'https://www.example.com' + } + }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(204); + expect(res.headers['Access-Control-Allow-Origin']).toBe('https://www.example.com'); + expect(res.headers['Access-Control-Allow-Credentials']).toBe('true'); + expect(res.headers['Access-Control-Allow-Methods']).toBe('POST, OPTIONS'); + expect(res.headers['Access-Control-Allow-Headers']).toBe('Content-Type'); + expect(res.headers.Vary).toBe('Origin'); + expect(res.end).toHaveBeenCalled(); + }); + + it('rejects forbidden origins before verifying Google token', async () => { + const req: any = { + method: 'POST', + headers: { + origin: 'https://evil.example.com' + }, + body: { + credential: 'credential' + } + }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toMatchObject({ + code: 403, + message: 'Forbidden origin' + }); + expect(verifyIdToken).not.toHaveBeenCalled(); + }); + + it('returns 400 when credential is missing', async () => { + const req: any = { + method: 'POST', + headers: { + origin: 'https://www.example.com' + }, + body: {} + }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toMatchObject({ + code: 400, + message: 'credential is invalid' + }); + }); + + it('signs in with verified Google payload and sets shared auth cookie', async () => { + const req: any = { + method: 'POST', + headers: { + origin: 'https://www.example.com', + 'x-forwarded-proto': 'https' + }, + body: { + credential: 'credential' + } + }; + const res = createMockRes(); + + await handler(req, res); + + expect(verifyIdToken).toHaveBeenCalledWith({ + idToken: 'credential', + audience: 'google-client-id' + }); + expect(mockPersistImage).toHaveBeenCalledWith( + 'https://example.com/avatar.png', + 'avatar/GOOGLE/google-sub' + ); + expect(mockGetGlobalToken).toHaveBeenCalledWith({ + provider: 'GOOGLE', + providerId: 'google-sub', + name: 'Ada Lovelace', + avatar_url: 'persisted-avatar', + email: 'ada@example.com' + }); + expect(res.body).toMatchObject({ + code: 200, + data: { + token: 'global-token' + } + }); + expect(res.headers['Set-Cookie']).toContain('sealos_auth_token=global-token'); + expect(res.headers['Set-Cookie']).toContain('HttpOnly'); + expect(res.headers['Set-Cookie']).toContain('Secure'); + expect(res.headers['Set-Cookie']).toContain('SameSite=Lax'); + }); + + it('does not pass unverified email into account binding', async () => { + getPayload.mockReturnValue({ + sub: 'google-sub', + name: 'Ada Lovelace', + picture: '', + email: 'ada@example.com', + email_verified: false + }); + const req: any = { + method: 'POST', + headers: { + origin: 'https://www.example.com' + }, + body: { + credential: 'credential' + } + }; + const res = createMockRes(); + + await handler(req, res); + + expect(mockGetGlobalToken).toHaveBeenCalledWith({ + provider: 'GOOGLE', + providerId: 'google-sub', + name: 'Ada Lovelace', + avatar_url: '', + email: undefined + }); + }); + + it('returns 401 when Google token verification fails', async () => { + verifyIdToken.mockRejectedValue(new Error('bad token')); + const req: any = { + method: 'POST', + headers: { + origin: 'https://www.example.com' + }, + body: { + credential: 'credential' + } + }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toMatchObject({ + code: 401, + message: 'Unauthorized' + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/google/onetap.ts b/frontend/desktop/src/pages/api/auth/google/onetap.ts new file mode 100644 index 000000000000..6f5672039417 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/google/onetap.ts @@ -0,0 +1,166 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { OAuth2Client } from 'google-auth-library'; +import { enableGoogle } from '@/services/enable'; +import { persistImage } from '@/services/backend/persistImage'; +import { getGlobalToken } from '@/services/backend/globalAuth'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { jsonRes } from '@/services/backend/response'; +import { SHARED_AUTH_COOKIE_NAME } from '@/utils/cookieUtils'; +import { ProviderType } from 'prisma/global/generated/client'; + +const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; + +const getAllowedOrigins = () => global.AppConfig?.desktop.auth.idp.google?.oneTapOrigins || []; + +const getRequestOrigin = (req: NextApiRequest) => req.headers.origin; + +const isAllowedOrigin = (origin: string | undefined) => + !!origin && getAllowedOrigins().includes(origin); + +const applyCorsHeaders = (req: NextApiRequest, res: NextApiResponse) => { + const origin = getRequestOrigin(req); + if (!origin || !isAllowedOrigin(origin)) return false; + + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Vary', 'Origin'); + return true; +}; + +const isHttpsRequest = (req: NextApiRequest) => { + const forwardedProto = req.headers['x-forwarded-proto']; + const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + return proto === 'https'; +}; + +const serializeAuthCookie = (token: string, secure: boolean) => + [ + `${SHARED_AUTH_COOKIE_NAME}=${encodeURIComponent(token)}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${COOKIE_MAX_AGE}`, + ...(secure ? ['Secure'] : []) + ].join('; '); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!applyCorsHeaders(req, res)) { + if (req.method === 'OPTIONS') { + return res.status(403).end(); + } + return jsonRes(res, { + code: 403, + message: 'Forbidden origin' + }); + } + + if (req.method === 'OPTIONS') { + return res.status(204).end(); + } + + if (req.method !== 'POST') { + return jsonRes(res, { + code: 405, + message: 'Method not allowed' + }); + } + + if (!enableGoogle()) { + return jsonRes(res, { + code: 500, + message: 'google client is not defined' + }); + } + + const credential = typeof req.body?.credential === 'string' ? req.body.credential : ''; + if (!credential) { + return jsonRes(res, { + code: 400, + message: 'credential is invalid' + }); + } + + const clientID = global.AppConfig?.desktop.auth.idp.google?.clientID; + if (!clientID) { + return jsonRes(res, { + code: 500, + message: 'google client is not defined' + }); + } + + let payload; + try { + const ticket = await new OAuth2Client(clientID).verifyIdToken({ + idToken: credential, + audience: clientID + }); + payload = ticket.getPayload(); + } catch { + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + } + + if (!payload?.sub) { + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + } + + const avatarUrl = payload.picture + ? await persistImage(payload.picture, 'avatar/' + ProviderType.GOOGLE + '/' + payload.sub) + .then((url) => url || '') + .catch(() => '') + : ''; + + const data = await getGlobalToken({ + provider: ProviderType.GOOGLE, + providerId: payload.sub, + name: payload.name || '', + avatar_url: avatarUrl, + email: payload.email_verified ? payload.email : undefined + }); + + if (data?.isRestricted) { + return jsonRes(res, { + code: 401, + message: 'Account banned' + }); + } + + if (!data) { + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + } + + if (data.error) { + return jsonRes(res, { + code: 40000, + data, + message: 'Unauthorized' + }); + } + + const token = data.token; + if (!token) { + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + } + + res.setHeader('Set-Cookie', serializeAuthCookie(token, isHttpsRequest(req))); + return jsonRes(res, { + data, + code: 200, + message: 'Successfully' + }); +} + +export default ErrorHandler(handler); diff --git a/frontend/desktop/src/pages/api/platform/getAuthConfig.ts b/frontend/desktop/src/pages/api/platform/getAuthConfig.ts index 6c4949e2c00f..633e423c75e5 100644 --- a/frontend/desktop/src/pages/api/platform/getAuthConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getAuthConfig.ts @@ -46,7 +46,8 @@ function genResAuthClientConfig(conf: AuthConfigType) { google: { enabled: !!conf.idp.google?.enabled, clientID: conf.idp.google?.clientID || '', - proxyAddress: conf.idp.google?.proxyAddress || '' + proxyAddress: conf.idp.google?.proxyAddress || '', + oneTapOrigins: conf.idp.google?.oneTapOrigins || [] }, oauth2: { enabled: !!conf.idp.oauth2?.enabled, diff --git a/frontend/desktop/src/types/system.ts b/frontend/desktop/src/types/system.ts index 3ba0fe7888fa..fa9a5bf95946 100644 --- a/frontend/desktop/src/types/system.ts +++ b/frontend/desktop/src/types/system.ts @@ -147,6 +147,7 @@ export type AuthConfigType = { proxyAddress?: string; clientID: string; clientSecret?: string; + oneTapOrigins?: string[]; }; oauth2?: { enabled: boolean; @@ -357,7 +358,8 @@ export const DefaultAuthClientConfig: AuthClientConfigType = { google: { enabled: false, clientID: '', - proxyAddress: '' + proxyAddress: '', + oneTapOrigins: [] }, sms: { enabled: false diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d950c14dec2b..0d0121c13133 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: framer-motion: specifier: ^10.16.4 version: 10.16.5(react-dom@18.3.1)(react@18.3.1) + google-auth-library: + specifier: ^10.9.0 + version: 10.9.0 i18next: specifier: ^23.11.5 version: 23.12.1 @@ -16679,6 +16682,11 @@ packages: dependencies: assert-plus: 1.0.0 + /data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dev: false + /data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -19480,6 +19488,14 @@ packages: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} dev: false + /fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + dev: false + /fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} dev: true @@ -19730,6 +19746,13 @@ packages: engines: {node: '>=0.4.x'} dev: false + /formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + dependencies: + fetch-blob: 3.2.0 + dev: false + /formidable@3.5.1: resolution: {integrity: sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==} dependencies: @@ -19885,6 +19908,28 @@ packages: engines: {node: '>=10'} dev: false + /gaxios@7.1.6: + resolution: {integrity: sha512-aIQ0QL8Or8vsUhHyXGA6AohOFRrAAiHhrvsAG6myzcSlfhxSXtnwXA/pRuQTilFgjhLe30swK5rg1d7E1f8Izw==} + engines: {node: '>=18'} + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + dev: false + + /gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + dependencies: + gaxios: 7.1.6 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /geist@1.4.2(next@14.2.35): resolution: {integrity: sha512-OQUga/KUc8ueijck6EbtT07L4tZ5+TZgjw8PyWfxo16sL5FWk7gNViPNU8hgCFjy6bJi9yuTP+CRpywzaGN8zw==} peerDependencies: @@ -20131,6 +20176,25 @@ packages: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} dev: true + /google-auth-library@10.9.0: + resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} + engines: {node: '>=18'} + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.6 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + dev: false + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -20661,7 +20725,6 @@ packages: debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: true /https@1.0.0: resolution: {integrity: sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==} @@ -21567,6 +21630,14 @@ packages: safe-buffer: 5.2.1 dev: false + /jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + /jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} dependencies: @@ -21574,6 +21645,13 @@ packages: safe-buffer: 5.2.1 dev: false + /jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + dev: false + /jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -23727,6 +23805,15 @@ packages: whatwg-url: 5.0.0 dev: false + /node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + dev: false + /node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true