From e60b4bafe1e7ecaed1ae93b9d2c863b51074115f Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Tue, 28 May 2024 14:25:40 +0200 Subject: [PATCH] feat: Add google authentication package, cleanup old code (#7496) --- .../core/types/src/auth/providers/google.ts | 6 + .../core/types/src/auth/providers/index.ts | 1 + packages/modules/auth/jest.config.js | 1 - packages/modules/auth/src/providers/google.ts | 224 ------------------ packages/modules/auth/src/providers/index.ts | 1 - packages/modules/auth/tsconfig.json | 3 +- .../modules/providers/auth-google/.gitignore | 0 .../modules/providers/auth-google/README.md | 11 + .../__tests__/services.spec.ts | 216 +++++++++++++++++ .../providers/auth-google/jest.config.js | 7 + .../providers/auth-google/package.json | 42 ++++ .../providers/auth-google/src/index.ts | 10 + .../auth-google/src/services/google.ts | 185 +++++++++++++++ .../providers/auth-google/tsconfig.json | 30 +++ yarn.lock | 22 ++ 15 files changed, 531 insertions(+), 228 deletions(-) create mode 100644 packages/core/types/src/auth/providers/google.ts delete mode 100644 packages/modules/auth/src/providers/google.ts delete mode 100644 packages/modules/auth/src/providers/index.ts create mode 100644 packages/modules/providers/auth-google/.gitignore create mode 100644 packages/modules/providers/auth-google/README.md create mode 100644 packages/modules/providers/auth-google/integration-tests/__tests__/services.spec.ts create mode 100644 packages/modules/providers/auth-google/jest.config.js create mode 100644 packages/modules/providers/auth-google/package.json create mode 100644 packages/modules/providers/auth-google/src/index.ts create mode 100644 packages/modules/providers/auth-google/src/services/google.ts create mode 100644 packages/modules/providers/auth-google/tsconfig.json diff --git a/packages/core/types/src/auth/providers/google.ts b/packages/core/types/src/auth/providers/google.ts new file mode 100644 index 0000000000..8d06a460b8 --- /dev/null +++ b/packages/core/types/src/auth/providers/google.ts @@ -0,0 +1,6 @@ +export interface GoogleAuthProviderOptions { + clientID: string + clientSecret: string + callbackURL: string + successRedirectUrl?: string +} diff --git a/packages/core/types/src/auth/providers/index.ts b/packages/core/types/src/auth/providers/index.ts index 5f6f11eafa..9925b4a041 100644 --- a/packages/core/types/src/auth/providers/index.ts +++ b/packages/core/types/src/auth/providers/index.ts @@ -1 +1,2 @@ export * from "./emailpass" +export * from "./google" diff --git a/packages/modules/auth/jest.config.js b/packages/modules/auth/jest.config.js index 21d1539c24..0c652264ea 100644 --- a/packages/modules/auth/jest.config.js +++ b/packages/modules/auth/jest.config.js @@ -4,7 +4,6 @@ module.exports = { "^@services": "/src/services", "^@repositories": "/src/repositories", "^@types": "/src/types", - "^@providers": "/src/providers", }, transform: { "^.+\\.[jt]s?$": [ diff --git a/packages/modules/auth/src/providers/google.ts b/packages/modules/auth/src/providers/google.ts deleted file mode 100644 index ad1f62a3d0..0000000000 --- a/packages/modules/auth/src/providers/google.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { AuthenticationInput, AuthenticationResponse } from "@medusajs/types" -import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils" -import jwt, { JwtPayload } from "jsonwebtoken" - -import { AuthorizationCode } from "simple-oauth2" -import url from "url" - -type InjectedDependencies = { - authIdentityService: any -} - -type ProviderConfig = { - clientID: string - clientSecret: string - callbackURL: string - successRedirectUrl?: string -} - -class GoogleProvider extends AbstractAuthModuleProvider { - protected readonly authIdentityService_: any - - constructor({ authIdentityService }: InjectedDependencies, options: any) { - super(arguments[0], { - provider: "google", - displayName: "Google Authentication", - }) - - this.authIdentityService_ = authIdentityService - } - - async authenticate( - req: AuthenticationInput - ): Promise { - if (req.query?.error) { - return { - success: false, - error: `${req.query.error_description}, read more at: ${req.query.error_uri}`, - } - } - - let config: ProviderConfig - - try { - config = await this.getProviderConfig(req) - } catch (error) { - return { success: false, error: error.message } - } - - return this.getRedirect(config) - } - - async validateCallback( - req: AuthenticationInput - ): Promise { - if (req.query && req.query.error) { - return { - success: false, - error: `${req.query.error_description}, read more at: ${req.query.error_uri}`, - } - } - - let config: ProviderConfig - - try { - config = await this.getProviderConfig(req) - } catch (error) { - return { success: false, error: error.message } - } - - const code = req.query?.code ?? req.body?.code - if (!code) { - return { success: false, error: "No code provided" } - } - - return await this.validateCallbackToken(code, config) - } - - // abstractable - async verify_(refreshToken: string) { - const jwtData = jwt.decode(refreshToken, { - complete: true, - }) as JwtPayload - const entity_id = jwtData.payload.email - - let authIdentity - - try { - authIdentity = - await this.authIdentityService_.retrieveByProviderAndEntityId( - entity_id, - this.provider - ) - } catch (error) { - if (error.type === MedusaError.Types.NOT_FOUND) { - const [createdAuthIdentity] = await this.authIdentityService_.create([ - { - entity_id, - provider: this.provider, - user_metadata: jwtData!.payload, - }, - ]) - authIdentity = createdAuthIdentity - } else { - return { success: false, error: error.message } - } - } - - return { - success: true, - authIdentity, - } - } - - // abstractable - private async validateCallbackToken( - code: string, - { clientID, callbackURL, clientSecret }: ProviderConfig - ) { - const client = this.getAuthorizationCodeHandler({ clientID, clientSecret }) - - const tokenParams = { - code, - redirect_uri: callbackURL, - } - - try { - const accessToken = await client.getToken(tokenParams) - - const { authIdentity, success } = await this.verify_( - accessToken.token.id_token - ) - - const { successRedirectUrl } = this.getConfig() - - return { - success, - authIdentity, - successRedirectUrl, - } - } catch (error) { - return { success: false, error: error.message } - } - } - - private getConfig(): ProviderConfig { - // TODO: Fetch this from provider config - // const config: Partial = { ...this.scopeConfig_ } - const config = {} as any - - if (!config.clientID) { - throw new Error("Google clientID is required") - } - - if (!config.clientSecret) { - throw new Error("Google clientSecret is required") - } - - if (!config.callbackURL) { - throw new Error("Google callbackUrl is required") - } - - return config as ProviderConfig - } - - private originalURL(req: AuthenticationInput) { - const host = req.headers?.host - const protocol = req.protocol - const path = req.url || "" - - return protocol + "://" + host + path - } - - private async getProviderConfig( - req: AuthenticationInput - ): Promise { - const config = this.getConfig() - - const callbackURL = config.callbackURL - - const parsedCallbackUrl = !url.parse(callbackURL).protocol - ? url.resolve(this.originalURL(req), callbackURL) - : callbackURL - - return { ...config, callbackURL: parsedCallbackUrl } - } - - // Abstractable - private getRedirect({ clientID, callbackURL, clientSecret }: ProviderConfig) { - const client = this.getAuthorizationCodeHandler({ clientID, clientSecret }) - - const location = client.authorizeURL({ - redirect_uri: callbackURL, - scope: "email profile", - }) - - return { success: true, location } - } - - private getAuthorizationCodeHandler({ - clientID, - clientSecret, - }: { - clientID: string - clientSecret: string - }) { - const config = { - client: { - id: clientID, - secret: clientSecret, - }, - auth: { - // TODO: abstract to not be google specific - authorizeHost: "https://accounts.google.com", - authorizePath: "/o/oauth2/v2/auth", - tokenHost: "https://www.googleapis.com", - tokenPath: "/oauth2/v4/token", - }, - } - - return new AuthorizationCode(config) - } -} - -export default GoogleProvider diff --git a/packages/modules/auth/src/providers/index.ts b/packages/modules/auth/src/providers/index.ts deleted file mode 100644 index 14dabd356d..0000000000 --- a/packages/modules/auth/src/providers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as GoogleProvider } from "./google" diff --git a/packages/modules/auth/tsconfig.json b/packages/modules/auth/tsconfig.json index 8b8d51ce5c..87b2674a45 100644 --- a/packages/modules/auth/tsconfig.json +++ b/packages/modules/auth/tsconfig.json @@ -24,8 +24,7 @@ "@models": ["./src/models"], "@services": ["./src/services"], "@repositories": ["./src/repositories"], - "@types": ["./src/types"], - "@providers": ["./src/providers"] + "@types": ["./src/types"] } }, "include": ["src"], diff --git a/packages/modules/providers/auth-google/.gitignore b/packages/modules/providers/auth-google/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/auth-google/README.md b/packages/modules/providers/auth-google/README.md new file mode 100644 index 0000000000..df3785bb37 --- /dev/null +++ b/packages/modules/providers/auth-google/README.md @@ -0,0 +1,11 @@ +## Testing + +In order to manually test the flow, you can do the following: + +1. Register an app in `https://console.cloud.google.com/apis/credentials/consent` +2. Generate clientID and clientSecret credentials in the console +3. Replace the values in the test with your credentials +4. Remove the `server.listen()` call +5. Run the tests, get the `location` value from the `authenticate` test, open the browser +6. Once redirected, copy the `code` param from the URL, and add it in one of the `callback` success tests +7. Once you run the tests, you should get back an access token, id token, and so on. diff --git a/packages/modules/providers/auth-google/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/auth-google/integration-tests/__tests__/services.spec.ts new file mode 100644 index 0000000000..c33b0f7e4c --- /dev/null +++ b/packages/modules/providers/auth-google/integration-tests/__tests__/services.spec.ts @@ -0,0 +1,216 @@ +import { MedusaError } from "@medusajs/utils" +import { GoogleAuthService } from "../../src/services/google" +jest.setTimeout(100000) +import { http, HttpResponse } from "msw" +import { setupServer } from "msw/node" +import jwt from "jsonwebtoken" + +const sampleIdPayload = { + iss: "https://accounts.google.com", + azp: "199301612397-l1lrg08vd6dvu98r43l7ul0ri2rd2b6r.apps.googleusercontent.com", + aud: "199301612397-l1lrg08vd6dvu98r43l7ul0ri2rd2b6r.apps.googleusercontent.com", + sub: "113664482950786663866", + hd: "medusajs.com", + email: "test@medusajs.com", + email_verified: true, + at_hash: "7DKi89ceSj-Bii1m_V1Pew", + name: "Test Admin", + picture: + "https://lh3.googleusercontent.com/a/ACg8ocJu6nzIGJRzHnl6peAh3fKOzOkrrRCWyMOMuIfCwePDG-ykulA=s96-c", + given_name: "Test", + family_name: "Admin", + iat: 1716891837, + exp: 1716895437, +} + +const encodedIdToken = jwt.sign(sampleIdPayload, "test") + +const baseUrl = "https://someurl.com" + +// This is just a network-layer mocking, it doesn't start an actual server +const server = setupServer( + http.post( + "https://oauth2.googleapis.com/token", + async ({ request, params, cookies }) => { + const url = request.url + if ( + url === + "https://oauth2.googleapis.com/token?client_id=test&client_secret=test&code=invalid-code&redirect_uri=https%3A%2F%2Fsomeurl.com%2Fauth%2Fgoogle%2Fcallback&grant_type=authorization_code" + ) { + return new HttpResponse(null, { + status: 401, + statusText: "Unauthorized", + }) + } + + if ( + url === + "https://oauth2.googleapis.com/token?client_id=test&client_secret=test&code=valid-code&redirect_uri=https%3A%2F%2Fsomeurl.com%2Fauth%2Fgoogle%2Fcallback&grant_type=authorization_code" + ) { + return new HttpResponse( + JSON.stringify({ + access_token: "test", + expires_in: 3600, + token_type: "Bearer", + refresh_token: "test", + id_token: encodedIdToken, + }) + ) + } + } + ), + + http.all("*", ({ request, params, cookies }) => { + return new HttpResponse(null, { + status: 404, + statusText: "Not Found", + }) + }) +) + +describe("Google auth provider", () => { + let googleService: GoogleAuthService + beforeAll(() => { + googleService = new GoogleAuthService( + { + logger: console as any, + }, + { + clientID: "test", + clientSecret: "test", + successRedirectUrl: baseUrl, + callbackURL: `${baseUrl}/auth/google/callback`, + } + ) + + server.listen() + }) + + afterEach(() => { + server.resetHandlers() + jest.restoreAllMocks() + }) + + afterAll(() => server.close()) + + it("throw an error if required options are not passed", async () => { + let msg = "" + try { + new GoogleAuthService( + { + logger: console as any, + }, + { + clientID: "test", + clientSecret: "test", + } as any + ) + } catch (e) { + msg = e.message + } + + expect(msg).toEqual("Google callbackUrl is required") + }) + + it("returns a redirect URL on authenticate", async () => { + const res = await googleService.authenticate({}) + expect(res).toEqual({ + success: true, + location: + "https://accounts.google.com/o/oauth2/v2/auth?redirect_uri=https%3A%2F%2Fsomeurl.com%2Fauth%2Fgoogle%2Fcallback&client_id=test&response_type=code&scope=email+profile+openid", + }) + }) + + it("validate callback should return an error on empty code", async () => { + const res = await googleService.validateCallback( + { + query: {}, + }, + {} as any + ) + expect(res).toEqual({ + success: false, + error: "No code provided", + }) + }) + + it("validate callback should return on a missing access token for code", async () => { + const res = await googleService.validateCallback( + { + query: { + code: "invalid-code", + }, + }, + {} as any + ) + + expect(res).toEqual({ + success: false, + error: "Could not exchange token, 401, Unauthorized", + }) + }) + + it("validate callback should return successfully on a correct code for a new user", async () => { + const authServiceSpies = { + retrieve: jest.fn().mockImplementation(() => { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Not found") + }), + create: jest.fn().mockImplementation(() => { + return { + entity_id: "test@admin.com", + provider: "google", + } + }), + } + + const res = await googleService.validateCallback( + { + query: { + code: "valid-code", + }, + }, + authServiceSpies + ) + + expect(res).toEqual({ + success: true, + successRedirectUrl: baseUrl, + authIdentity: { + entity_id: "test@admin.com", + provider: "google", + }, + }) + }) + + it("validate callback should return successfully on a correct code for an existing user", async () => { + const authServiceSpies = { + retrieve: jest.fn().mockImplementation(() => { + return { + entity_id: "test@admin.com", + provider: "google", + } + }), + create: jest.fn().mockImplementation(() => { + return {} + }), + } + + const res = await googleService.validateCallback( + { + query: { + code: "valid-code", + }, + }, + authServiceSpies + ) + + expect(res).toEqual({ + success: true, + successRedirectUrl: baseUrl, + authIdentity: { + entity_id: "test@admin.com", + provider: "google", + }, + }) + }) +}) diff --git a/packages/modules/providers/auth-google/jest.config.js b/packages/modules/providers/auth-google/jest.config.js new file mode 100644 index 0000000000..9cf8a99080 --- /dev/null +++ b/packages/modules/providers/auth-google/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + transform: { + "^.+\\.[jt]s?$": "@swc/jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], +} diff --git a/packages/modules/providers/auth-google/package.json b/packages/modules/providers/auth-google/package.json new file mode 100644 index 0000000000..929b2a61fc --- /dev/null +++ b/packages/modules/providers/auth-google/package.json @@ -0,0 +1,42 @@ +{ + "name": "@medusajs/auth-google", + "version": "0.0.1", + "description": "Google OAuth authentication provider for Medusa", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/providers/auth-google" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "prepublishOnly": "cross-env NODE_ENV=production tsc --build", + "test": "jest --passWithNoTests src", + "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts", + "build": "rimraf dist && tsc -p ./tsconfig.json", + "watch": "tsc --watch" + }, + "devDependencies": { + "@types/simple-oauth2": "^5.0.7", + "cross-env": "^5.2.1", + "jest": "^29.6.3", + "msw": "^2.3.0", + "rimraf": "^5.0.1", + "typescript": "^5.1.6" + }, + "dependencies": { + "@medusajs/utils": "^1.11.7", + "jsonwebtoken": "^9.0.2" + }, + "keywords": [ + "medusa-provider", + "medusa-provider-auth-google" + ] +} diff --git a/packages/modules/providers/auth-google/src/index.ts b/packages/modules/providers/auth-google/src/index.ts new file mode 100644 index 0000000000..34efdea219 --- /dev/null +++ b/packages/modules/providers/auth-google/src/index.ts @@ -0,0 +1,10 @@ +import { ModuleProviderExports } from "@medusajs/types" +import { GoogleAuthService } from "./services/google" + +const services = [GoogleAuthService] + +const providerExport: ModuleProviderExports = { + services, +} + +export default providerExport diff --git a/packages/modules/providers/auth-google/src/services/google.ts b/packages/modules/providers/auth-google/src/services/google.ts new file mode 100644 index 0000000000..d83b2dcd0a --- /dev/null +++ b/packages/modules/providers/auth-google/src/services/google.ts @@ -0,0 +1,185 @@ +import { + Logger, + GoogleAuthProviderOptions, + AuthenticationResponse, + AuthenticationInput, + AuthIdentityProviderService, +} from "@medusajs/types" +import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils" +import jwt, { JwtPayload } from "jsonwebtoken" + +type InjectedDependencies = { + logger: Logger +} + +interface LocalServiceConfig extends GoogleAuthProviderOptions {} + +// TODO: Add state param that is stored in Redis, to prevent CSRF attacks +export class GoogleAuthService extends AbstractAuthModuleProvider { + protected config_: LocalServiceConfig + protected logger_: Logger + + constructor( + { logger }: InjectedDependencies, + options: GoogleAuthProviderOptions + ) { + super({}, { provider: "google", displayName: "Google Authentication" }) + this.validateConfig(options) + this.config_ = options + this.logger_ = logger + } + + async authenticate( + req: AuthenticationInput + ): Promise { + if (req.query?.error) { + return { + success: false, + error: `${req.query.error_description}, read more at: ${req.query.error_uri}`, + } + } + + return this.getRedirect(this.config_) + } + + async validateCallback( + req: AuthenticationInput, + authIdentityService: AuthIdentityProviderService + ): Promise { + if (req.query && req.query.error) { + return { + success: false, + error: `${req.query.error_description}, read more at: ${req.query.error_uri}`, + } + } + + const code = req.query?.code ?? req.body?.code + if (!code) { + return { success: false, error: "No code provided" } + } + + const params = `client_id=${this.config_.clientID}&client_secret=${ + this.config_.clientSecret + }&code=${code}&redirect_uri=${encodeURIComponent( + this.config_.callbackURL + )}&grant_type=authorization_code` + const exchangeTokenUrl = new URL( + `https://oauth2.googleapis.com/token?${params}` + ) + + try { + const response = await fetch(exchangeTokenUrl.toString(), { + method: "POST", + }).then((r) => { + if (!r.ok) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Could not exchange token, ${r.status}, ${r.statusText}` + ) + } + + return r.json() + }) + + const { authIdentity, success } = await this.verify_( + response.id_token as string, + authIdentityService + ) + + return { + success, + authIdentity, + successRedirectUrl: this.config_.successRedirectUrl, + } + } catch (error) { + return { success: false, error: error.message } + } + } + + async verify_( + idToken: string | undefined, + authIdentityService: AuthIdentityProviderService + ) { + if (!idToken) { + return { success: false, error: "No ID found" } + } + + const jwtData = jwt.decode(idToken, { + complete: true, + }) as JwtPayload + const payload = jwtData.payload + + if (!payload.email_verified) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Email not verified, cannot proceed with authentication" + ) + } + + // TODO: We should probably use something else than email here, like the `sub` field (which is more constant than the email) + const entity_id = payload.email + const userMetadata = { + name: payload.name, + picture: payload.picture, + given_name: payload.given_name, + family_name: payload.family_name, + } + + let authIdentity + + try { + authIdentity = await authIdentityService.retrieve({ + entity_id, + provider: this.provider, + }) + } catch (error) { + if (error.type === MedusaError.Types.NOT_FOUND) { + const createdAuthIdentity = await authIdentityService.create({ + entity_id, + provider: this.provider, + user_metadata: userMetadata, + }) + authIdentity = createdAuthIdentity + } else { + return { success: false, error: error.message } + } + } + + return { + success: true, + authIdentity, + } + } + + private getRedirect({ clientID, callbackURL }: LocalServiceConfig) { + const redirectUrlParam = `redirect_uri=${encodeURIComponent(callbackURL)}` + const clientIdParam = `client_id=${clientID}` + const responseTypeParam = "response_type=code" + const scopeParam = "scope=email+profile+openid" + + const authUrl = new URL( + `https://accounts.google.com/o/oauth2/v2/auth?${[ + redirectUrlParam, + clientIdParam, + responseTypeParam, + scopeParam, + ].join("&")}` + ) + + return { success: true, location: authUrl.toString() } + } + + private validateConfig(config: LocalServiceConfig) { + if (!config.clientID) { + throw new Error("Google clientID is required") + } + + if (!config.clientSecret) { + throw new Error("Google clientSecret is required") + } + + if (!config.callbackURL) { + throw new Error("Google callbackUrl is required") + } + } +} diff --git a/packages/modules/providers/auth-google/tsconfig.json b/packages/modules/providers/auth-google/tsconfig.json new file mode 100644 index 0000000000..9b6a615689 --- /dev/null +++ b/packages/modules/providers/auth-google/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "lib": ["es2021"], + "target": "es2021", + "outDir": "./dist", + "esModuleInterop": true, + "declaration": true, + "module": "ES2020", + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "allowJs": true, + "skipLibCheck": true, + "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */ + }, + "include": ["src"], + "exclude": [ + "dist", + "build", + "src/**/__tests__", + "src/**/__mocks__", + "src/**/__fixtures__", + "node_modules", + ".eslintrc.js" + ] +} diff --git a/yarn.lock b/yarn.lock index 54649dfda7..c35aad6b8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4991,6 +4991,21 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/auth-google@workspace:packages/modules/providers/auth-google": + version: 0.0.0-use.local + resolution: "@medusajs/auth-google@workspace:packages/modules/providers/auth-google" + dependencies: + "@medusajs/utils": ^1.11.7 + "@types/simple-oauth2": ^5.0.7 + cross-env: ^5.2.1 + jest: ^29.6.3 + jsonwebtoken: ^9.0.2 + msw: ^2.3.0 + rimraf: ^5.0.1 + typescript: ^5.1.6 + languageName: unknown + linkType: soft + "@medusajs/auth@workspace:*, @medusajs/auth@workspace:packages/modules/auth": version: 0.0.0-use.local resolution: "@medusajs/auth@workspace:packages/modules/auth" @@ -11778,6 +11793,13 @@ __metadata: languageName: node linkType: hard +"@types/simple-oauth2@npm:^5.0.7": + version: 5.0.7 + resolution: "@types/simple-oauth2@npm:5.0.7" + checksum: 1cfb87b1ee4b58b30e4f9753271ea73282e707553b9b3360755064e4c7b1e96a01cabc5e83d13841369595ea7e451d3d576bea02340b4f17f9e930f54c08a15e + languageName: node + linkType: hard + "@types/stack-utils@npm:^1.0.1": version: 1.0.1 resolution: "@types/stack-utils@npm:1.0.1"