diff --git a/.yarn/cache/@napi-rs-keyring-darwin-arm64-npm-1.3.0-fd07951a9d-10.zip b/.yarn/cache/@napi-rs-keyring-darwin-arm64-npm-1.3.0-fd07951a9d-10.zip new file mode 100644 index 000000000..6f8b04c47 Binary files /dev/null and b/.yarn/cache/@napi-rs-keyring-darwin-arm64-npm-1.3.0-fd07951a9d-10.zip differ diff --git a/.yarn/cache/@napi-rs-keyring-darwin-x64-npm-1.3.0-9cbf53bfb9-10.zip b/.yarn/cache/@napi-rs-keyring-darwin-x64-npm-1.3.0-9cbf53bfb9-10.zip new file mode 100644 index 000000000..3a6b64b7b Binary files /dev/null and b/.yarn/cache/@napi-rs-keyring-darwin-x64-npm-1.3.0-9cbf53bfb9-10.zip differ diff --git a/.yarn/cache/@napi-rs-keyring-linux-arm64-gnu-npm-1.3.0-078668775d-10.zip b/.yarn/cache/@napi-rs-keyring-linux-arm64-gnu-npm-1.3.0-078668775d-10.zip new file mode 100644 index 000000000..8fc904611 Binary files /dev/null and b/.yarn/cache/@napi-rs-keyring-linux-arm64-gnu-npm-1.3.0-078668775d-10.zip differ diff --git a/.yarn/cache/@napi-rs-keyring-linux-x64-gnu-npm-1.3.0-54a406b5fe-10.zip b/.yarn/cache/@napi-rs-keyring-linux-x64-gnu-npm-1.3.0-54a406b5fe-10.zip new file mode 100644 index 000000000..df0f37620 Binary files /dev/null and b/.yarn/cache/@napi-rs-keyring-linux-x64-gnu-npm-1.3.0-54a406b5fe-10.zip differ diff --git a/.yarn/cache/@napi-rs-keyring-npm-1.3.0-d4b5dd8636-bfef7fe1df.zip b/.yarn/cache/@napi-rs-keyring-npm-1.3.0-d4b5dd8636-bfef7fe1df.zip new file mode 100644 index 000000000..5f7c8d92b Binary files /dev/null and b/.yarn/cache/@napi-rs-keyring-npm-1.3.0-d4b5dd8636-bfef7fe1df.zip differ diff --git a/.yarn/cache/oauth4webapi-npm-3.8.6-d99b72248c-980568a712.zip b/.yarn/cache/oauth4webapi-npm-3.8.6-d99b72248c-980568a712.zip new file mode 100644 index 000000000..db2b0fb6c Binary files /dev/null and b/.yarn/cache/oauth4webapi-npm-3.8.6-d99b72248c-980568a712.zip differ diff --git a/LICENSES-3rdparty.csv b/LICENSES-3rdparty.csv index b49ace056..1c6521d72 100644 --- a/LICENSES-3rdparty.csv +++ b/LICENSES-3rdparty.csv @@ -175,6 +175,19 @@ Component,Origin,Licence,Copyright @module-federation/sdk,npm,MIT,zhanghang (https://www.npmjs.com/package/@module-federation/sdk) @module-federation/webpack-bundler-runtime,npm,MIT,zhanghang (https://www.npmjs.com/package/@module-federation/webpack-bundler-runtime) @mswjs/interceptors,npm,MIT,Artem Zakharchenko (https://www.npmjs.com/package/@mswjs/interceptors) +@napi-rs/keyring,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring) +@napi-rs/keyring-darwin-arm64,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-darwin-arm64) +@napi-rs/keyring-darwin-x64,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-darwin-x64) +@napi-rs/keyring-freebsd-x64,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-freebsd-x64) +@napi-rs/keyring-linux-arm-gnueabihf,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-linux-arm-gnueabihf) +@napi-rs/keyring-linux-arm64-gnu,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-linux-arm64-gnu) +@napi-rs/keyring-linux-arm64-musl,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-linux-arm64-musl) +@napi-rs/keyring-linux-riscv64-gnu,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-linux-riscv64-gnu) +@napi-rs/keyring-linux-x64-gnu,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-linux-x64-gnu) +@napi-rs/keyring-linux-x64-musl,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-linux-x64-musl) +@napi-rs/keyring-win32-arm64-msvc,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-win32-arm64-msvc) +@napi-rs/keyring-win32-ia32-msvc,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-win32-ia32-msvc) +@napi-rs/keyring-win32-x64-msvc,npm,MIT,(https://www.npmjs.com/package/@napi-rs/keyring-win32-x64-msvc) @nodelib/fs.scandir,npm,MIT,(https://www.npmjs.com/package/@nodelib/fs.scandir) @nodelib/fs.stat,npm,MIT,(https://www.npmjs.com/package/@nodelib/fs.stat) @nodelib/fs.walk,npm,MIT,(https://www.npmjs.com/package/@nodelib/fs.walk) @@ -621,6 +634,7 @@ npm-run-path,npm,MIT,Sindre Sorhus (sindresorhus.com) npmlog,npm,ISC,Isaac Z. Schlueter (http://blog.izs.me/) number-is-nan,npm,MIT,Sindre Sorhus (sindresorhus.com) oauth-sign,npm,Apache-2.0,Mikeal Rogers (http://www.futurealoof.com) +oauth4webapi,npm,MIT,Filip Skokan (https://github.com/panva/oauth4webapi) object-assign,npm,MIT,Sindre Sorhus (sindresorhus.com) object-inspect,npm,MIT,James Halliday (https://github.com/inspect-js/object-inspect) object-keys,npm,MIT,Jordan Harband (http://ljharb.codes) diff --git a/packages/core/src/helpers/env.ts b/packages/core/src/helpers/env.ts index 88e8fa10f..b1cda732d 100644 --- a/packages/core/src/helpers/env.ts +++ b/packages/core/src/helpers/env.ts @@ -22,6 +22,8 @@ const yellow = chalk.bold.yellow; // - DATADOG_APPS_UPLOAD_ASSETS // - DD_APPS_VERSION_NAME // - DATADOG_APPS_VERSION_NAME +// - DD_AUTH_METHOD +// - DATADOG_AUTH_METHOD // - DD_SITE // - DATADOG_SITE const OVERRIDE_VARIABLES = [ @@ -31,6 +33,7 @@ const OVERRIDE_VARIABLES = [ 'APPS_INTAKE_URL', 'APPS_UPLOAD_ASSETS', 'APPS_VERSION_NAME', + 'AUTH_METHOD', 'SITE', ] as const; type ENV_KEY = (typeof OVERRIDE_VARIABLES)[number]; diff --git a/packages/core/src/helpers/request.test.ts b/packages/core/src/helpers/request.test.ts index 2826cd785..7e4515edd 100644 --- a/packages/core/src/helpers/request.test.ts +++ b/packages/core/src/helpers/request.test.ts @@ -185,7 +185,7 @@ describe('Request Helpers', () => { expect(scope.isDone()).toBe(true); }); - test('Should add authentication headers when needed.', async () => { + test('Should add authentication headers when using API and APP keys.', async () => { const fetchMock = jest .spyOn(global, 'fetch') .mockImplementation(() => Promise.resolve(new Response('{}'))); @@ -209,5 +209,27 @@ describe('Request Helpers', () => { }), ); }); + + test('Should add bearer authentication headers when using OAuth.', async () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(new Response('{}'))); + const { doRequest } = await import('@dd/core/helpers/request'); + await doRequest({ + ...requestOpts, + auth: { + accessToken: 'access-token', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + getIntakeUrl(DEFAULT_SITE), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + }), + }), + ); + }); }); }); diff --git a/packages/core/src/helpers/request.ts b/packages/core/src/helpers/request.ts index 329cc259b..2d746521b 100644 --- a/packages/core/src/helpers/request.ts +++ b/packages/core/src/helpers/request.ts @@ -117,6 +117,10 @@ export const doRequest = (opts: RequestOpts): Promise => { }; // Do auth if present. + if (auth?.accessToken) { + requestHeaders.Authorization = `Bearer ${auth.accessToken}`; + } + if (auth?.apiKey) { requestHeaders['DD-API-KEY'] = auth.apiKey; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1e6c45234..d50209819 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -245,9 +245,13 @@ export type GetWrappedPlugins = (arg: GetPluginsArg) => (PluginOptions | CustomP export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none'; export type Site = (typeof SITES)[number]; +export type AuthMethod = 'apiKey' | 'oauth'; + export type AuthOptions = { apiKey?: string; appKey?: string; + accessToken?: string; + method?: AuthMethod; site?: string; }; @@ -283,7 +287,7 @@ export type PluginName = `datadog-${Lowercase}-plugin`; type Data = { data?: BodyInit; headers?: Record }; export type RequestOpts = { url: string; - auth?: Pick; + auth?: Pick; method?: string; getData?: () => Promise | Data; type?: 'json' | 'text'; diff --git a/packages/factory/src/validate.ts b/packages/factory/src/validate.ts index 50f4458b6..686b9fa84 100644 --- a/packages/factory/src/validate.ts +++ b/packages/factory/src/validate.ts @@ -57,6 +57,7 @@ export const validateOptions = (options: Options = {}): OptionsWithDefaults => { const envSite = resolveSite(envRaw, 'DATADOG_SITE/DD_SITE', errors); const auth: AuthOptionsWithDefaults = { + method: options.auth?.method, site: envSite ?? resolveSite(options.auth?.site, 'auth.site', errors) ?? DEFAULT_SITE, }; @@ -75,6 +76,11 @@ export const validateOptions = (options: Options = {}): OptionsWithDefaults => { enumerable: false, }); + Object.defineProperty(auth, 'accessToken', { + value: options.auth?.accessToken, + enumerable: false, + }); + return { enableGit: true, logLevel: 'warn', diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 4490ca1ad..289f45f74 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -18,6 +18,7 @@ A plugin to upload assets to Datadog's storage - [apps.dryRun](#appsdryrun) - [apps.enable](#appsenable) - [apps.include](#appsinclude) + - [auth.method](#authmethod) - [apps.identifier](#appsidentifier) - [apps.name](#appsname) @@ -25,6 +26,13 @@ A plugin to upload assets to Datadog's storage ## Configuration ```ts +auth?: { + method?: 'apiKey' | 'oauth'; + apiKey?: string; + appKey?: string; + site?: string; +} + apps?: { dryRun?: boolean; enable?: boolean; @@ -66,6 +74,22 @@ Must be a boolean. Non-boolean values are coerced today but will be rejected in Additional glob patterns (relative to the project root) to include in the uploaded archive. The bundler output directory is always included. +### auth.method + +> default: `apiKey` + +Authentication method for uploading app bundles. + +Use `apiKey` to send `DD_API_KEY`/`DD_APP_KEY` credentials. Use `oauth` to complete a local Authorization Code + PKCE flow and upload with a short-lived bearer token instead. + +You can also set `DATADOG_AUTH_METHOD=oauth` or `DD_AUTH_METHOD=oauth`. + +When `auth.method` is `oauth`, the plugin derives OAuth client settings from the resolved Datadog site. The plugin reads tokens from the OS credential store, refreshes expired access tokens when a refresh token is available, and only starts browser authorization when no usable stored token exists. + +For first-time authorization, the plugin starts a temporary local HTTP callback server, opens Datadog authorization in the browser, exchanges the authorization code with PKCE, and saves the returned token response for later uploads. + +OAuth token and authorization URLs are site-based. The `datad0g.com` site uses the internal Datadog Apps OAuth client; all other sites use the default Datadog Apps OAuth client. + ### apps.identifier > default: an internal computation between the `name` and `repository` fields in `package.json` or from the `git` plugin. diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index a09626633..80f2ab42b 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -31,10 +31,12 @@ }, "dependencies": { "@dd/core": "workspace:*", + "@napi-rs/keyring": "1.3.0", "chalk": "2.3.1", "eslint-scope": "7.2.2", "glob": "11.1.0", "jszip": "3.10.1", + "oauth4webapi": "3.8.6", "pretty-bytes": "5.6.0" }, "devDependencies": { diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 0ef4bc662..09b7534e0 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -5,6 +5,7 @@ import * as archive from '@dd/apps-plugin/archive'; import * as assets from '@dd/apps-plugin/assets'; import * as identifier from '@dd/apps-plugin/identifier'; +import * as oauth from '@dd/apps-plugin/oauth'; import * as uploader from '@dd/apps-plugin/upload'; import { getPlugins } from '@dd/apps-plugin'; import { DEFAULT_SITE } from '@dd/core/constants'; @@ -24,6 +25,8 @@ import path from 'path'; import { parseAst } from 'rollup/parseAst'; import { APPS_API_PATH } from './constants'; +import type { AppsOptionsWithDefaults } from './types'; +import { handleUpload } from './vite/handle-upload'; /** Extract and assert closeBundle from the first plugin's vite hooks. */ function extractCloseBundle(plugins: PluginOptions[]) { @@ -211,8 +214,9 @@ describe('Apps Plugin - getPlugins', () => { expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { - apiKey: '123', - appKey: '123', + accessToken: undefined, + apiKey: undefined, + appKey: undefined, bundlerName: 'vite', dryRun: true, identifier: 'repo:app', @@ -230,6 +234,109 @@ describe('Apps Plugin - getPlugins', () => { expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-')); }); + test('Should authorize with OAuth before uploading assets when configured', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ + identifier: 'repo:app', + name: 'test-app', + }); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, + ]); + jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); + jest.spyOn(archive, 'createArchive').mockResolvedValue({ + archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', + assets: [], + size: 10, + }); + jest.spyOn(oauth, 'getOAuthToken').mockResolvedValue({ + accessToken: 'oauth-token', + site: 'datadoghq.eu', + }); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ errors: [], warnings: [] }); + + const closeBundle = extractCloseBundle( + getPlugins( + getGetPluginsArg( + { + auth: { + method: 'oauth', + site: DEFAULT_SITE, + }, + apps: { + dryRun: false, + }, + }, + { + bundler: { ...getMockBundler({ name: 'vite' }), outDir }, + buildRoot, + git: getRepositoryDataMock({ remote: 'git@github.com:org/repo.git' }), + }, + ), + ), + ); + await closeBundle(); + + expect(oauth.getOAuthToken).toHaveBeenCalledWith( + DEFAULT_SITE, + expect.objectContaining({ + authorizationUrl: `https://api.${DEFAULT_SITE}/oauth2/v1/authorize`, + tokenUrl: `https://api.${DEFAULT_SITE}/oauth2/v1/token`, + }), + expect.anything(), + ); + expect(uploader.uploadArchive).toHaveBeenCalledWith( + expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), + expect.objectContaining({ + accessToken: 'oauth-token', + apiKey: undefined, + appKey: undefined, + site: 'datadoghq.eu', + }), + expect.anything(), + ); + }); + + test('Should use API credentials when upload method is not specified', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ + identifier: 'repo:app', + name: 'test-app', + }); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([ + { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, + ]); + jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined); + jest.spyOn(archive, 'createArchive').mockResolvedValue({ + archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', + assets: [], + size: 10, + }); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ errors: [], warnings: [] }); + const getOAuthTokenSpy = jest.spyOn(oauth, 'getOAuthToken'); + + await handleUpload({ + backendFunctions: [], + backendOutputs: new Map(), + context: getArgs().context, + options: { + dryRun: false, + include: [], + oauth: oauth.getOAuthConfig(DEFAULT_SITE), + } as unknown as AppsOptionsWithDefaults, + }); + + expect(getOAuthTokenSpy).not.toHaveBeenCalled(); + expect(uploader.uploadArchive).toHaveBeenCalledWith( + expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), + expect.objectContaining({ + accessToken: undefined, + apiKey: '123', + appKey: '123', + site: DEFAULT_SITE, + }), + expect.anything(), + ); + }); + test('Should emit root manifest.json with backend function connection allowlists', async () => { jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({ identifier: 'repo:app', diff --git a/packages/plugins/apps/src/oauth-dependencies.ts b/packages/plugins/apps/src/oauth-dependencies.ts new file mode 100644 index 000000000..2e23304cd --- /dev/null +++ b/packages/plugins/apps/src/oauth-dependencies.ts @@ -0,0 +1,35 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +export type OAuthAuthorizationServer = import('oauth4webapi').AuthorizationServer; +export type OAuthClient = import('oauth4webapi').Client; +export type OAuthModule = typeof import('oauth4webapi'); +export type OAuthTokenEndpointResponse = import('oauth4webapi').TokenEndpointResponse; + +type KeyringModule = typeof import('@napi-rs/keyring'); + +const OAUTH_PACKAGE_NAME = 'oauth4webapi'; +const KEYRING_PACKAGE_NAME = '@napi-rs/keyring'; + +const memoizeAsync = (load: () => Promise) => { + let promise: Promise | undefined; + + return () => { + if (!promise) { + promise = load().catch((error) => { + promise = undefined; + throw error; + }); + } + return promise; + }; +}; + +export const loadOauth = memoizeAsync(() => import(OAUTH_PACKAGE_NAME) as Promise); + +// `@napi-rs/keyring` has a CommonJS entry. Load it lazily so API-key uploads +// do not initialize the native credential binding. +export const loadKeyring = memoizeAsync( + () => import(KEYRING_PACKAGE_NAME) as Promise, +); diff --git a/packages/plugins/apps/src/oauth.test.ts b/packages/plugins/apps/src/oauth.test.ts new file mode 100644 index 000000000..686440839 --- /dev/null +++ b/packages/plugins/apps/src/oauth.test.ts @@ -0,0 +1,362 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { + authorizeWithPKCE, + buildAuthorizationUrl, + deleteOAuthTokenFromKeychain, + exchangeAuthorizationCode, + getOAuthConfig, + getOAuthToken, + readOAuthTokenFromKeychain, + validateOAuthCallback, + writeOAuthTokenToKeychain, +} from '@dd/apps-plugin/oauth'; +import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import nock from 'nock'; +import stripAnsi from 'strip-ansi'; + +const mockKeyringStore = new Map(); + +jest.mock('oauth4webapi', () => { + const postTokenRequest = (url: string, body: Record): Promise => { + return fetch(url, { + body: new URLSearchParams(body).toString(), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + method: 'POST', + }); + }; + + const processTokenResponse = async (response: Response) => { + const token = (await response.json()) as Record; + if (typeof token.token_type === 'string') { + return { ...token, token_type: token.token_type.toLowerCase() }; + } + + return token; + }; + + return { + None: () => undefined, + authorizationCodeGrantRequest: ( + authorizationServer: { token_endpoint: string }, + client: { client_id: string }, + _clientAuth: unknown, + callbackParameters: URLSearchParams, + redirectUri: string, + codeVerifier: string, + ) => + postTokenRequest(authorizationServer.token_endpoint, { + client_id: client.client_id, + code: callbackParameters.get('code') || '', + code_verifier: codeVerifier, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }), + calculatePKCECodeChallenge: async (codeVerifier: string) => `challenge-${codeVerifier}`, + processAuthorizationCodeResponse: ( + _authorizationServer: unknown, + _client: unknown, + response: Response, + ) => processTokenResponse(response), + processRefreshTokenResponse: ( + _authorizationServer: unknown, + _client: unknown, + response: Response, + ) => processTokenResponse(response), + refreshTokenGrantRequest: ( + authorizationServer: { token_endpoint: string }, + client: { client_id: string }, + _clientAuth: unknown, + refreshToken: string, + ) => + postTokenRequest(authorizationServer.token_endpoint, { + client_id: client.client_id, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + validateAuthResponse: ( + _authorizationServer: unknown, + _client: unknown, + callbackUrl: URL, + expectedState: string, + ) => { + if (callbackUrl.searchParams.get('state') !== expectedState) { + throw new Error('Invalid OAuth state.'); + } + + return callbackUrl.searchParams; + }, + }; +}); + +jest.mock('@napi-rs/keyring', () => ({ + AsyncEntry: class { + private readonly key: string; + + constructor(service: string, username: string) { + this.key = `${service}:${username}`; + } + + async deletePassword() { + mockKeyringStore.delete(this.key); + } + + async getPassword() { + return mockKeyringStore.get(this.key); + } + + async setPassword(password: string) { + mockKeyringStore.set(this.key, password); + } + }, +})); + +const getAuthorizationUrlFromLog = (message: string) => { + const match = stripAnsi(message).match(/https:\/\/\S+/); + if (!match) { + throw new Error(`Expected authorization URL in log message: ${message}`); + } + + return new URL(match[0]); +}; + +const createAuthorizationUrlLogger = () => { + let resolveUrl: (url: URL) => void = () => {}; + let rejectUrl: (error: unknown) => void = () => {}; + const url = new Promise((resolve, reject) => { + resolveUrl = resolve; + rejectUrl = reject; + }); + + const info = jest.fn((message: string) => { + try { + resolveUrl(getAuthorizationUrlFromLog(message)); + } catch (error) { + rejectUrl(error); + } + }); + + return { info, reject: rejectUrl, url }; +}; + +const normalizeFormBody = (body: unknown) => { + if (typeof body === 'string') { + return Object.fromEntries(new URLSearchParams(body)); + } + + return body; +}; + +const createOAuthConfig = (overrides: Partial> = {}) => ({ + ...getOAuthConfig('datadoghq.com'), + clientId: 'client-id', + openBrowser: false, + timeoutMs: 1000, + ...overrides, +}); + +describe('Apps Plugin - OAuth', () => { + beforeEach(() => { + mockKeyringStore.clear(); + }); + + afterEach(() => { + nock.cleanAll(); + nock.disableNetConnect(); + }); + + test('Should build Datadog OAuth authorization URL with PKCE parameters', () => { + const url = buildAuthorizationUrl({ + authorizationUrl: 'https://api.datadoghq.com/oauth2/v1/authorize', + clientId: 'client-id', + codeChallenge: 'challenge', + redirectUri: 'http://localhost:8060', + state: 'state', + }); + + expect(url.toString()).toBe( + 'https://api.datadoghq.com/oauth2/v1/authorize?redirect_uri=http%3A%2F%2Flocalhost%3A8060&client_id=client-id&response_type=code&code_challenge=challenge&code_challenge_method=S256&state=state', + ); + }); + + test('Should exchange authorization code for token', async () => { + const bodies: unknown[] = []; + const scope = nock('https://api.datadoghq.com') + .post('/oauth2/v1/token', (body) => { + bodies.push(body); + return true; + }) + .reply(200, { + access_token: 'access-token', + expires_in: 3600, + refresh_token: 'refresh-token', + token_type: 'Bearer', + }); + + const token = await exchangeAuthorizationCode({ + callbackParameters: await validateOAuthCallback( + createOAuthConfig(), + new URL('http://localhost:8060?code=code&state=state'), + 'state', + ), + authorizationUrl: 'https://api.datadoghq.com/oauth2/v1/authorize', + clientId: 'client-id', + codeVerifier: 'verifier', + redirectUri: 'http://localhost:8060', + site: 'datadoghq.com', + tokenUrl: 'https://api.datadoghq.com/oauth2/v1/token', + }); + + expect(scope.isDone()).toBe(true); + expect(normalizeFormBody(bodies[0])).toEqual({ + client_id: 'client-id', + code: 'code', + code_verifier: 'verifier', + grant_type: 'authorization_code', + redirect_uri: 'http://localhost:8060', + }); + expect(token).toEqual( + expect.objectContaining({ + accessToken: 'access-token', + expiresIn: 3600, + refreshToken: 'refresh-token', + site: 'datadoghq.com', + tokenType: 'bearer', + }), + ); + expect(token.expiresAt).toEqual(expect.any(Number)); + }); + + test('Should use cached access token when it is still valid', async () => { + await writeOAuthTokenToKeychain( + 'datadoghq.com', + { + accessToken: 'cached-token', + clientId: 'client-id', + expiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'refresh-token', + site: 'datadoghq.com', + tokenType: 'bearer', + }, + createOAuthConfig(), + ); + + const token = await getOAuthToken('datadoghq.com', createOAuthConfig(), getMockLogger()); + + expect(token).toEqual({ + accessToken: 'cached-token', + expiresAt: expect.any(Number), + refreshToken: 'refresh-token', + site: 'datadoghq.com', + tokenType: 'bearer', + }); + }); + + test('Should refresh cached token when it is expired', async () => { + const bodies: unknown[] = []; + await writeOAuthTokenToKeychain( + 'datadoghq.com', + { + accessToken: 'expired-token', + clientId: 'client-id', + expiresAt: Date.now() - 1000, + refreshToken: 'old-refresh-token', + site: 'datadoghq.com', + tokenType: 'bearer', + }, + createOAuthConfig(), + ); + const scope = nock('https://api.datadoghq.com') + .post('/oauth2/v1/token', (body) => { + bodies.push(body); + return true; + }) + .reply(200, { + access_token: 'refreshed-token', + expires_in: 3600, + token_type: 'Bearer', + }); + + const token = await getOAuthToken('datadoghq.com', createOAuthConfig(), getMockLogger()); + const cachedToken = await readOAuthTokenFromKeychain('datadoghq.com', createOAuthConfig()); + + expect(scope.isDone()).toBe(true); + expect(normalizeFormBody(bodies[0])).toEqual({ + client_id: 'client-id', + grant_type: 'refresh_token', + refresh_token: 'old-refresh-token', + }); + expect(token).toEqual( + expect.objectContaining({ + accessToken: 'refreshed-token', + refreshToken: 'old-refresh-token', + site: 'datadoghq.com', + tokenType: 'bearer', + }), + ); + expect(cachedToken).toEqual( + expect.objectContaining({ + accessToken: 'refreshed-token', + refreshToken: 'old-refresh-token', + }), + ); + }); + + test('Should store tokens in the OS credential store', async () => { + await writeOAuthTokenToKeychain( + 'datadoghq.com', + { + accessToken: 'cached-token', + clientId: 'client-id', + site: 'datadoghq.com', + }, + createOAuthConfig(), + ); + + await expect( + readOAuthTokenFromKeychain('datadoghq.com', createOAuthConfig()), + ).resolves.toEqual({ + accessToken: 'cached-token', + clientId: 'client-id', + site: 'datadoghq.com', + }); + + await deleteOAuthTokenFromKeychain('datadoghq.com', createOAuthConfig()); + await expect( + readOAuthTokenFromKeychain('datadoghq.com', createOAuthConfig()), + ).resolves.toBeUndefined(); + }); + + test('Should authorize with PKCE using a local callback', async () => { + const port = 18060; + const redirectUri = `http://127.0.0.1:${port}`; + const authorizationUrlLogger = createAuthorizationUrlLogger(); + const logger = getMockLogger({ info: authorizationUrlLogger.info }); + nock.enableNetConnect('127.0.0.1'); + const scope = nock('https://api.datadoghq.com') + .post('/oauth2/v1/token') + .reply(200, { access_token: 'access-token', token_type: 'Bearer' }); + + const tokenPromise = authorizeWithPKCE( + 'datadoghq.com', + createOAuthConfig({ redirectUri, timeoutMs: 2000 }), + logger, + ); + tokenPromise.catch(authorizationUrlLogger.reject); + + const authorizeUrl = await authorizationUrlLogger.url; + const state = authorizeUrl.searchParams.get('state'); + expect(state).toBeTruthy(); + + const response = await fetch(`${redirectUri}?code=code&state=${state}`); + expect(response.ok).toBe(true); + + await expect(tokenPromise).resolves.toMatchObject({ + accessToken: 'access-token', + site: 'datadoghq.com', + }); + expect(scope.isDone()).toBe(true); + }); +}); diff --git a/packages/plugins/apps/src/oauth.ts b/packages/plugins/apps/src/oauth.ts new file mode 100644 index 000000000..f187d7914 --- /dev/null +++ b/packages/plugins/apps/src/oauth.ts @@ -0,0 +1,596 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Logger } from '@dd/core/types'; +import chalk from 'chalk'; +import { spawn } from 'child_process'; +import { createHash, randomBytes } from 'crypto'; +import http from 'http'; + +import { + loadKeyring, + loadOauth, + type OAuthAuthorizationServer, + type OAuthClient, + type OAuthModule, + type OAuthTokenEndpointResponse, +} from './oauth-dependencies'; +import type { AppsOAuthConfig } from './types'; + +export const DEFAULT_APPS_OAUTH_CLIENT_ID = 'e17b9ffa-3daf-4124-ba1b-4ac8c547d506'; +export const DATAD0G_APPS_OAUTH_CLIENT_ID = 'f4bacdd2-0c8c-49f5-bf3e-a62ba3ec02e6'; +export const DEFAULT_APPS_OAUTH_REDIRECT_URI = 'http://localhost:8060'; +export const DEFAULT_APPS_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +export const OAUTH_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000; +export const OAUTH_KEYCHAIN_SERVICE = 'datadog-build-plugins:apps-oauth'; + +type OAuthCallback = { + callbackParameters: URLSearchParams; + domain?: string; +}; + +export type OAuthToken = { + accessToken: string; + expiresAt?: number; + expiresIn?: number; + refreshToken?: string; + scope?: string; + site: string; + tokenType?: string; +}; + +export type CachedOAuthToken = Omit & { + clientId: string; +}; + +type StoredOAuthCredential = { + token: CachedOAuthToken; + version: 1; +}; + +const cyan = chalk.cyan.bold; + +const base64Url = (buffer: Buffer) => buffer.toString('base64url'); + +export const generateCodeVerifier = () => base64Url(randomBytes(32)); + +export const generateCodeChallenge = async (codeVerifier: string) => { + const oauth = await loadOauth(); + return oauth.calculatePKCECodeChallenge(codeVerifier); +}; + +export const getOAuthClientId = (site: string) => { + switch (site) { + case 'datad0g.com': + return DATAD0G_APPS_OAUTH_CLIENT_ID; + default: + return DEFAULT_APPS_OAUTH_CLIENT_ID; + } +}; + +export const getOAuthConfig = (site: string): AppsOAuthConfig => { + const clientId = getOAuthClientId(site); + const baseOAuthUrl = `https://api.${site}/oauth2/v1`; + + return { + authorizationUrl: `${baseOAuthUrl}/authorize`, + cacheTokens: true, + clientId, + openBrowser: true, + redirectUri: DEFAULT_APPS_OAUTH_REDIRECT_URI, + timeoutMs: DEFAULT_APPS_OAUTH_TIMEOUT_MS, + tokenUrl: `${baseOAuthUrl}/token`, + }; +}; + +const getOAuthClient = (clientId: string): OAuthClient => ({ client_id: clientId }); + +const getAuthorizationServer = ( + options: Pick, +): OAuthAuthorizationServer => { + return { + issuer: new URL(options.authorizationUrl).origin, + authorization_endpoint: options.authorizationUrl, + token_endpoint: options.tokenUrl, + }; +}; + +const getOAuthCredentialFingerprint = ( + site: string, + options: Pick, +) => + createHash('sha256') + .update([options.clientId, site, options.authorizationUrl, options.tokenUrl].join('|')) + .digest('hex') + .slice(0, 16); + +const getOAuthCredentialAccount = ( + site: string, + options: Pick, +) => `${site}:${options.clientId}:${getOAuthCredentialFingerprint(site, options)}`; + +export const buildAuthorizationUrl = (opts: { + authorizationUrl: string; + clientId: string; + codeChallenge: string; + redirectUri: string; + state: string; +}) => { + const url = new URL(opts.authorizationUrl); + url.searchParams.set('redirect_uri', opts.redirectUri); + url.searchParams.set('client_id', opts.clientId); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('code_challenge', opts.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('state', opts.state); + return url; +}; + +const tryOpenBrowser = (url: string) => { + const opener = + process.platform === 'darwin' + ? { command: 'open', args: [url] } + : process.platform === 'win32' + ? { command: 'cmd', args: ['/c', 'start', '', url] } + : { command: 'xdg-open', args: [url] }; + + try { + const child = spawn(opener.command, opener.args, { + detached: true, + stdio: 'ignore', + }); + child.unref(); + } catch { + // Logging the URL is the reliable fallback. + } +}; + +const respond = (res: http.ServerResponse, statusCode: number, body: string) => { + res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=UTF-8' }); + res.end(body); +}; + +export const waitForOAuthCallback = async (opts: { + authorizationServer: OAuthAuthorizationServer; + client: OAuthClient; + oauth: OAuthModule; + redirectUri: string; + state: string; + timeoutMs: number; +}): Promise => { + const redirectUrl = new URL(opts.redirectUri); + const port = Number(redirectUrl.port || 80); + + if (redirectUrl.protocol !== 'http:') { + throw new Error('OAuth redirect URI must use http for the local OAuth callback.'); + } + + if (!Number.isInteger(port) || port <= 0) { + throw new Error('OAuth redirect URI must include a valid port.'); + } + + let timeout: ReturnType | undefined; + let settled = false; + + const server = http.createServer(); + + try { + return await new Promise((resolve, reject) => { + const finish = (fn: () => void) => { + if (settled) { + return; + } + settled = true; + fn(); + }; + + server.on('request', (req, res) => { + const reqUrl = new URL(req.url || '/', redirectUrl.origin); + if (reqUrl.pathname !== redirectUrl.pathname) { + respond(res, 404, 'Not found.'); + return; + } + + let callbackParameters: URLSearchParams; + try { + callbackParameters = opts.oauth.validateAuthResponse( + opts.authorizationServer, + opts.client, + reqUrl, + opts.state, + ); + } catch (error) { + respond(res, 400, 'OAuth authorization failed. You may now close this tab.'); + finish(() => reject(error instanceof Error ? error : new Error(String(error)))); + return; + } + + const code = callbackParameters.get('code'); + if (!code) { + respond( + res, + 400, + 'Missing OAuth authorization code. You may now close this tab.', + ); + finish(() => reject(new Error('Missing OAuth authorization code.'))); + return; + } + + respond(res, 200, 'OAuth authorization complete. You may now close this tab.'); + finish(() => + resolve({ + callbackParameters, + domain: callbackParameters.get('domain') || undefined, + }), + ); + }); + + server.once('error', (error) => finish(() => reject(error))); + + timeout = setTimeout(() => { + finish(() => + reject( + new Error( + `Timed out waiting for OAuth callback after ${opts.timeoutMs}ms.`, + ), + ), + ); + }, opts.timeoutMs); + + try { + server.listen(port, redirectUrl.hostname); + } catch (error) { + finish(() => reject(error instanceof Error ? error : new Error(String(error)))); + } + }); + } finally { + if (timeout) { + clearTimeout(timeout); + } + if (server.listening) { + server.close(); + server.closeAllConnections?.(); + server.closeIdleConnections?.(); + } + } +}; + +export const exchangeAuthorizationCode = async (opts: { + callbackParameters: URLSearchParams; + clientId: string; + codeVerifier: string; + redirectUri: string; + site: string; + tokenUrl: string; + authorizationUrl: string; +}): Promise => { + const oauth = await loadOauth(); + const authorizationServer = getAuthorizationServer(opts); + const client = getOAuthClient(opts.clientId); + const response = await oauth.authorizationCodeGrantRequest( + authorizationServer, + client, + oauth.None(), + opts.callbackParameters, + opts.redirectUri, + opts.codeVerifier, + ); + const tokenResponse = await oauth.processAuthorizationCodeResponse( + authorizationServer, + client, + response, + ); + + return tokenResponseToOAuthToken(tokenResponse, opts.site); +}; + +export const validateOAuthCallback = async ( + options: Pick, + callbackUrl: URL, + state: string, +) => { + const oauth = await loadOauth(); + return oauth.validateAuthResponse( + getAuthorizationServer(options), + getOAuthClient(options.clientId), + callbackUrl, + state, + ); +}; + +export const refreshOAuthToken = async ( + site: string, + options: Pick, + refreshToken: string, +): Promise => { + const oauth = await loadOauth(); + const authorizationServer = getAuthorizationServer(options); + const client = getOAuthClient(options.clientId); + const response = await oauth.refreshTokenGrantRequest( + authorizationServer, + client, + oauth.None(), + refreshToken, + ); + const tokenResponse = await oauth.processRefreshTokenResponse( + authorizationServer, + client, + response, + ); + + return tokenResponseToOAuthToken( + { + ...tokenResponse, + refresh_token: tokenResponse.refresh_token || refreshToken, + }, + site, + ); +}; + +const tokenResponseToOAuthToken = ( + tokenResponse: OAuthTokenEndpointResponse, + site: string, + receivedAt = Date.now(), +): OAuthToken => { + return { + accessToken: tokenResponse.access_token, + expiresAt: + typeof tokenResponse.expires_in === 'number' + ? receivedAt + tokenResponse.expires_in * 1000 + : undefined, + expiresIn: tokenResponse.expires_in, + refreshToken: tokenResponse.refresh_token, + scope: tokenResponse.scope, + site, + tokenType: tokenResponse.token_type, + }; +}; + +const isObject = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const getErrorCode = (error: unknown) => + isObject(error) && typeof error.code === 'string' ? error.code : undefined; + +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const isNoEntryError = (error: unknown) => { + const message = getErrorMessage(error).toLowerCase(); + return ( + getErrorCode(error) === 'NoEntry' || + message.includes('noentry') || + message.includes('no matching entry') || + message.includes('not found') + ); +}; + +const assertStoredOAuthCredential = (value: unknown): StoredOAuthCredential | undefined => { + if ( + isObject(value) && + value.version === 1 && + isObject(value.token) && + typeof value.token.accessToken === 'string' && + typeof value.token.clientId === 'string' && + typeof value.token.site === 'string' + ) { + return value as StoredOAuthCredential; + } + + return undefined; +}; + +const createOAuthCredentialEntry = async ( + site: string, + options: Pick, +) => { + const { AsyncEntry } = await loadKeyring(); + return new AsyncEntry(OAUTH_KEYCHAIN_SERVICE, getOAuthCredentialAccount(site, options)); +}; + +const secureStorageError = (operation: string, error: unknown) => + new Error( + `Could not ${operation} Datadog Apps OAuth token in the OS credential store. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + +export const readOAuthTokenFromKeychain = async ( + site: string, + options: Pick, +): Promise => { + const entry = await createOAuthCredentialEntry(site, options); + try { + const raw = await entry.getPassword(); + if (!raw) { + return undefined; + } + + const parsed: unknown = JSON.parse(raw); + return assertStoredOAuthCredential(parsed)?.token; + } catch (error) { + if (isNoEntryError(error)) { + return undefined; + } + + throw secureStorageError('read', error); + } +}; + +export const writeOAuthTokenToKeychain = async ( + site: string, + token: CachedOAuthToken, + options: Pick, +) => { + const entry = await createOAuthCredentialEntry(site, options); + const credential: StoredOAuthCredential = { version: 1, token }; + try { + await entry.setPassword(JSON.stringify(credential)); + } catch (error) { + throw secureStorageError('save', error); + } +}; + +export const deleteOAuthTokenFromKeychain = async ( + site: string, + options: Pick, +) => { + const entry = await createOAuthCredentialEntry(site, options); + try { + await entry.deletePassword(); + } catch (error) { + if (!isNoEntryError(error)) { + throw secureStorageError('delete', error); + } + } +}; + +const isCachedTokenValid = (token: CachedOAuthToken) => + token.expiresAt === undefined || token.expiresAt > Date.now() + OAUTH_TOKEN_EXPIRY_SKEW_MS; + +const toCachedToken = (token: OAuthToken, clientId: string): CachedOAuthToken => ({ + accessToken: token.accessToken, + clientId, + expiresAt: token.expiresAt, + refreshToken: token.refreshToken, + scope: token.scope, + site: token.site, + tokenType: token.tokenType, +}); + +const fromCachedToken = (token: CachedOAuthToken): OAuthToken => ({ + accessToken: token.accessToken, + expiresAt: token.expiresAt, + refreshToken: token.refreshToken, + scope: token.scope, + site: token.site, + tokenType: token.tokenType, +}); + +const saveOAuthToken = async ( + token: OAuthToken, + options: AppsOAuthConfig, + log: Logger, + cacheSite = token.site, +) => { + if (!options.cacheTokens) { + return; + } + + await writeOAuthTokenToKeychain(cacheSite, toCachedToken(token, options.clientId), options); + log.debug('Saved Datadog Apps OAuth token to the OS credential store.'); +}; + +const deleteOAuthToken = async (site: string, options: AppsOAuthConfig): Promise => { + if (!options.cacheTokens) { + return; + } + + await deleteOAuthTokenFromKeychain(site, options); +}; + +const getCachedOAuthToken = async ( + site: string, + options: AppsOAuthConfig, + log: Logger, +): Promise => { + if (!options.cacheTokens) { + return undefined; + } + + const cachedToken = await readOAuthTokenFromKeychain(site, options); + if (!cachedToken) { + return undefined; + } + + if (isCachedTokenValid(cachedToken)) { + log.debug('Using cached Datadog Apps OAuth access token.'); + return fromCachedToken(cachedToken); + } + + if (!cachedToken.refreshToken) { + return undefined; + } + + try { + log.debug('Refreshing cached Datadog Apps OAuth access token.'); + const refreshedToken = await refreshOAuthToken(site, options, cachedToken.refreshToken); + await saveOAuthToken(refreshedToken, options, log); + return refreshedToken; + } catch (error) { + log.warn( + `Cached Datadog Apps OAuth token could not be refreshed; starting browser authorization. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + await deleteOAuthToken(site, options); + return undefined; + } +}; + +export const authorizeWithPKCE = async ( + site: string, + options: AppsOAuthConfig, + log: Logger, +): Promise => { + const oauth = await loadOauth(); + const authorizationServer = getAuthorizationServer(options); + const client = getOAuthClient(options.clientId); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = base64Url(randomBytes(32)); + const authorizationUrl = buildAuthorizationUrl({ + authorizationUrl: options.authorizationUrl, + clientId: options.clientId, + codeChallenge, + redirectUri: options.redirectUri, + state, + }); + + const callbackPromise = waitForOAuthCallback({ + authorizationServer, + client, + oauth, + redirectUri: options.redirectUri, + state, + timeoutMs: options.timeoutMs, + }); + + log.info(`Authorize Datadog Apps upload:\n ${cyan(authorizationUrl.toString())}`); + + if (options.openBrowser) { + tryOpenBrowser(authorizationUrl.toString()); + } + + const callback = await callbackPromise; + const tokenSite = callback.domain || site; + const tokenConfig = callback.domain ? getOAuthConfig(tokenSite) : options; + return exchangeAuthorizationCode({ + callbackParameters: callback.callbackParameters, + clientId: tokenConfig.clientId, + codeVerifier, + redirectUri: tokenConfig.redirectUri, + site: tokenSite, + tokenUrl: tokenConfig.tokenUrl, + authorizationUrl: tokenConfig.authorizationUrl, + }); +}; + +export const getOAuthToken = async ( + site: string, + options: AppsOAuthConfig, + log: Logger, +): Promise => { + const cachedToken = await getCachedOAuthToken(site, options, log); + if (cachedToken) { + return cachedToken; + } + + const token = await authorizeWithPKCE(site, options, log); + await saveOAuthToken(token, options, log, site); + if (token.site !== site) { + await saveOAuthToken(token, getOAuthConfig(token.site), log); + } + return token; +}; diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts index c640c6e2c..01a4e7f2f 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -2,7 +2,17 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { WithRequired } from '@dd/core/types'; +import type { AuthMethod, WithRequired } from '@dd/core/types'; + +export type AppsOAuthConfig = { + authorizationUrl: string; + cacheTokens: boolean; + clientId: string; + openBrowser: boolean; + redirectUri: string; + timeoutMs: number; + tokenUrl: string; +}; export type AppsOptions = { enable?: boolean; @@ -28,4 +38,7 @@ export type AppsManifest = { export type AppsOptionsWithDefaults = Omit< WithRequired, 'enable' ->; +> & { + method: AuthMethod; + oauth: AppsOAuthConfig; +}; diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts index 58be236c7..e957b5f2d 100644 --- a/packages/plugins/apps/src/upload.test.ts +++ b/packages/plugins/apps/src/upload.test.ts @@ -194,12 +194,24 @@ describe('Apps Plugin - upload', () => { ); expect(errors).toHaveLength(1); expect(errors[0].message).toBe( - 'Missing authentication token, need both app and api keys.', + 'Missing authentication token, need either an OAuth access token or both app and api keys.', ); expect(warnings).toHaveLength(0); expect(doRequestMock).not.toHaveBeenCalled(); }); + test('Should not require authentication for dry runs', async () => { + const { errors, warnings } = await uploadArchive( + archive, + { ...context, apiKey: undefined, appKey: undefined, dryRun: true }, + logger, + ); + + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + expect(doRequestMock).not.toHaveBeenCalled(); + }); + test('Should fail when missing identifier', async () => { const { errors, warnings } = await uploadArchive( archive, @@ -258,6 +270,36 @@ describe('Apps Plugin - upload', () => { ); }); + test('Should upload archive with an OAuth access token', async () => { + doRequestMock.mockResolvedValue({ + version_id: 'v123', + application_id: 'app123', + app_builder_id: 'builder123', + } as any); + + const { errors, warnings } = await uploadArchive( + archive, + { + ...context, + accessToken: 'access-token', + apiKey: undefined, + appKey: undefined, + }, + logger, + ); + + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + expect(doRequestMock).toHaveBeenCalledWith({ + auth: { accessToken: 'access-token' }, + url: 'https://api.datadoghq.com/api/unstable/app-builder-code/apps/repo:app/upload', + method: 'POST', + type: 'json', + getData: expect.any(Function), + onRetry: expect.any(Function), + }); + }); + test('Should make PUT request to release version after successful upload', async () => { doRequestMock .mockResolvedValueOnce({ diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts index f0ca4e32a..0ade848aa 100644 --- a/packages/plugins/apps/src/upload.ts +++ b/packages/plugins/apps/src/upload.ts @@ -22,6 +22,7 @@ import { APPS_API_PATH, ARCHIVE_FILENAME } from './constants'; type DataResponse = Awaited>; export type UploadContext = { + accessToken?: string; apiKey?: string; appKey?: string; bundlerName: string; @@ -37,6 +38,18 @@ const yellow = chalk.yellow.bold; const cyan = chalk.cyan.bold; const bold = chalk.bold; +const getRequestAuth = (context: UploadContext) => { + if (context.accessToken) { + return { accessToken: context.accessToken }; + } + + if (context.apiKey && context.appKey) { + return { apiKey: context.apiKey, appKey: context.appKey }; + } + + return undefined; +}; + export const getIntakeUrl = (site: string, appId: string) => { const envIntake = getDDEnvValue('APPS_INTAKE_URL'); return envIntake || `https://api.${site}/${APPS_API_PATH}/${appId}/upload`; @@ -74,11 +87,6 @@ export const uploadArchive = async (archive: Archive, context: UploadContext, lo const errors: Error[] = []; const warnings: string[] = []; - if (!context.apiKey || !context.appKey) { - errors.push(new Error('Missing authentication token, need both app and api keys.')); - return { errors, warnings }; - } - if (!context.identifier) { errors.push(new Error('No app identifier provided')); return { errors, warnings }; @@ -113,9 +121,19 @@ Would have uploaded ${summary}`, return { errors, warnings }; } + const requestAuth = getRequestAuth(context); + if (!requestAuth) { + errors.push( + new Error( + 'Missing authentication token, need either an OAuth access token or both app and api keys.', + ), + ); + return { errors, warnings }; + } + try { const response: any = await doRequest({ - auth: { apiKey: context.apiKey, appKey: context.appKey }, + auth: requestAuth, url: intakeUrl, method: 'POST', type: 'json', @@ -140,7 +158,7 @@ Would have uploaded ${summary}`, if (response.version_id) { const releaseUrl = getReleaseUrl(context.site, context.identifier); await doRequest({ - auth: { apiKey: context.apiKey, appKey: context.appKey }, + auth: requestAuth, url: releaseUrl, method: 'PUT', type: 'json', diff --git a/packages/plugins/apps/src/validate.test.ts b/packages/plugins/apps/src/validate.test.ts index a660f1662..38335a01a 100644 --- a/packages/plugins/apps/src/validate.test.ts +++ b/packages/plugins/apps/src/validate.test.ts @@ -2,7 +2,17 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { + DATAD0G_APPS_OAUTH_CLIENT_ID, + DEFAULT_APPS_OAUTH_CLIENT_ID, + DEFAULT_APPS_OAUTH_REDIRECT_URI, + DEFAULT_APPS_OAUTH_TIMEOUT_MS, + getOAuthConfig, +} from '@dd/apps-plugin/oauth'; import { validateOptions } from '@dd/apps-plugin/validate'; +import { DEFAULT_SITE } from '@dd/core/constants'; + +const defaultOAuthConfig = getOAuthConfig(DEFAULT_SITE); describe('Apps Plugin - validateOptions', () => { describe('defaults', () => { @@ -12,7 +22,9 @@ describe('Apps Plugin - validateOptions', () => { dryRun: true, include: [], identifier: undefined, + method: 'apiKey', name: undefined, + oauth: defaultOAuthConfig, }); }); @@ -62,8 +74,73 @@ describe('Apps Plugin - validateOptions', () => { dryRun: true, include: ['public/**/*', 'dist/**/*'], identifier: 'my-app', + method: 'apiKey', name: undefined, + oauth: defaultOAuthConfig, + }); + }); + + test('Should enable OAuth method when configured', () => { + const result = validateOptions({ + auth: { + method: 'oauth', + }, + apps: { + enable: true, + }, + }); + + expect(result.method).toBe('oauth'); + expect(result.oauth).toEqual(defaultOAuthConfig); + }); + + test('Should derive OAuth endpoints and default client ID from the configured site', () => { + const result = validateOptions({ + auth: { + site: 'datadoghq.eu', + }, + apps: { + enable: true, + }, }); + + expect(result.oauth).toEqual({ + authorizationUrl: 'https://api.datadoghq.eu/oauth2/v1/authorize', + cacheTokens: true, + clientId: DEFAULT_APPS_OAUTH_CLIENT_ID, + openBrowser: true, + redirectUri: DEFAULT_APPS_OAUTH_REDIRECT_URI, + timeoutMs: DEFAULT_APPS_OAUTH_TIMEOUT_MS, + tokenUrl: 'https://api.datadoghq.eu/oauth2/v1/token', + }); + }); + + test('Should use the datad0g OAuth client ID for datad0g.com', () => { + const result = validateOptions({ + auth: { + site: 'datad0g.com', + }, + apps: { + enable: true, + }, + }); + + expect(result.oauth.clientId).toBe(DATAD0G_APPS_OAUTH_CLIENT_ID); + expect(result.oauth.authorizationUrl).toBe( + 'https://api.datad0g.com/oauth2/v1/authorize', + ); + expect(result.oauth.tokenUrl).toBe('https://api.datad0g.com/oauth2/v1/token'); + }); + + test('Should allow env vars to opt into OAuth', () => { + process.env.DATADOG_AUTH_METHOD = 'oauth'; + try { + const result = validateOptions({ apps: {} }); + expect(result.method).toBe('oauth'); + expect(result.oauth).toEqual(defaultOAuthConfig); + } finally { + delete process.env.DATADOG_AUTH_METHOD; + } }); }); }); diff --git a/packages/plugins/apps/src/validate.ts b/packages/plugins/apps/src/validate.ts index 38e75a61e..05f361e45 100644 --- a/packages/plugins/apps/src/validate.ts +++ b/packages/plugins/apps/src/validate.ts @@ -2,19 +2,42 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { DEFAULT_SITE } from '@dd/core/constants'; import { getDDEnvValue } from '@dd/core/helpers/env'; -import type { Options } from '@dd/core/types'; +import type { AuthMethod, Options } from '@dd/core/types'; import { CONFIG_KEY } from './constants'; +import { getOAuthConfig } from './oauth'; import type { AppsOptions, AppsOptionsWithDefaults } from './types'; +const AUTH_METHODS: AuthMethod[] = ['apiKey', 'oauth']; + +const resolveAuthMethod = (value: string | undefined): AuthMethod | undefined => { + if (value === undefined) { + return undefined; + } + + if (AUTH_METHODS.includes(value as AuthMethod)) { + return value as AuthMethod; + } + + throw new Error(`auth.method must be one of: ${AUTH_METHODS.join(', ')}`); +}; + export const validateOptions = (options: Options): AppsOptionsWithDefaults => { const resolvedOptions = (options[CONFIG_KEY] || {}) as AppsOptions; + const method = + resolveAuthMethod(getDDEnvValue('AUTH_METHOD')) || + resolveAuthMethod(options.auth?.method) || + 'apiKey'; + const site = options.auth?.site || DEFAULT_SITE; return { + method, include: resolvedOptions.include || [], dryRun: resolvedOptions.dryRun ?? !getDDEnvValue('APPS_UPLOAD_ASSETS'), identifier: resolvedOptions.identifier?.trim(), name: resolvedOptions.name?.trim() || options.metadata?.name?.trim(), + oauth: getOAuthConfig(site), }; }; diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index de8304b43..f28afe54b 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -27,7 +27,7 @@ type BundleFn = (func: BackendFunction) => Promise; const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:'; -type AuthConfig = Required; +type AuthConfig = Required>; /** Shape of the `outputs` field in a Datadog app-builder query response — * the API wraps a JS action's return value as `{ data: }`. diff --git a/packages/plugins/apps/src/vite/handle-upload.ts b/packages/plugins/apps/src/vite/handle-upload.ts index 9519f363a..ed69be9d0 100644 --- a/packages/plugins/apps/src/vite/handle-upload.ts +++ b/packages/plugins/apps/src/vite/handle-upload.ts @@ -16,6 +16,7 @@ import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; import { PLUGIN_NAME } from '../constants'; import { resolveIdentifier } from '../identifier'; +import { getOAuthToken } from '../oauth'; import type { AppsManifest, AppsOptionsWithDefaults } from '../types'; import { uploadArchive } from '../upload'; @@ -138,17 +139,36 @@ Either: // Store variable for later disposal of directory. archiveDir = path.dirname(archive.archivePath); + let uploadSite: string = auth.site; + let accessToken: string | undefined; + let apiKey: string | undefined; + let appKey: string | undefined; + if (!options.dryRun) { + if (options.method === 'oauth') { + const authTimer = log.time('authorize upload'); + const token = await getOAuthToken(auth.site, options.oauth, log).finally(() => + authTimer.end(), + ); + accessToken = token.accessToken; + uploadSite = token.site; + } else { + apiKey = auth.apiKey; + appKey = auth.appKey; + } + } + const uploadTimer = log.time('upload assets'); const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive( archive, { - apiKey: auth.apiKey, - appKey: auth.appKey, + accessToken, + apiKey, + appKey, bundlerName, dryRun: options.dryRun, identifier, name, - site: auth.site, + site: uploadSite, version, }, log, diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index d53ed20c1..f5a7ee9cf 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -90,8 +90,18 @@ const defaultOptions = { }), options: { enable: true, + method: 'apiKey' as const, include: [], dryRun: true, + oauth: { + authorizationUrl: 'https://api.datadoghq.com/oauth2/v1/authorize', + cacheTokens: true, + clientId: 'client-id', + openBrowser: false, + redirectUri: 'http://localhost:8060', + timeoutMs: 1000, + tokenUrl: 'https://api.datadoghq.com/oauth2/v1/token', + }, }, }; diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index 44e4cd5c0..ede797468 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -52,12 +52,14 @@ "dependencies": { "@datadog/js-instrumentation-wasm": "1.0.8", "@jridgewell/remapping": "2.3.5", + "@napi-rs/keyring": "1.3.0", "async-retry": "1.3.3", "chalk": "2.3.1", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "oauth4webapi": "3.8.6", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index c011f19fb..6e76ed2e8 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -55,12 +55,14 @@ "dependencies": { "@datadog/js-instrumentation-wasm": "1.0.8", "@jridgewell/remapping": "2.3.5", + "@napi-rs/keyring": "1.3.0", "async-retry": "1.3.3", "chalk": "2.3.1", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "oauth4webapi": "3.8.6", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index d55c2678a..a611dffca 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -52,12 +52,14 @@ "dependencies": { "@datadog/js-instrumentation-wasm": "1.0.8", "@jridgewell/remapping": "2.3.5", + "@napi-rs/keyring": "1.3.0", "async-retry": "1.3.3", "chalk": "2.3.1", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "oauth4webapi": "3.8.6", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index 3d4955bf2..4720238c5 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -52,12 +52,14 @@ "dependencies": { "@datadog/js-instrumentation-wasm": "1.0.8", "@jridgewell/remapping": "2.3.5", + "@napi-rs/keyring": "1.3.0", "async-retry": "1.3.3", "chalk": "2.3.1", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "oauth4webapi": "3.8.6", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index e85a2a1c9..e3df1a3da 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -52,12 +52,14 @@ "dependencies": { "@datadog/js-instrumentation-wasm": "1.0.8", "@jridgewell/remapping": "2.3.5", + "@napi-rs/keyring": "1.3.0", "async-retry": "1.3.3", "chalk": "2.3.1", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "oauth4webapi": "3.8.6", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/tools/src/commands/oss/apply.ts b/packages/tools/src/commands/oss/apply.ts index c175e239f..a4ba60e28 100644 --- a/packages/tools/src/commands/oss/apply.ts +++ b/packages/tools/src/commands/oss/apply.ts @@ -85,6 +85,78 @@ const DEPENDENCY_ADDITIONS: Record = { origin: 'npm', owner: 'JounQin (https://github.com/unrs/unrs-resolver#readme)', }, + '@napi-rs/keyring-darwin-arm64': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-darwin-arm64', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-darwin-arm64)', + }, + '@napi-rs/keyring-darwin-x64': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-darwin-x64', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-darwin-x64)', + }, + '@napi-rs/keyring-freebsd-x64': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-freebsd-x64', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-freebsd-x64)', + }, + '@napi-rs/keyring-linux-arm-gnueabihf': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-linux-arm-gnueabihf', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-linux-arm-gnueabihf)', + }, + '@napi-rs/keyring-linux-arm64-gnu': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-linux-arm64-gnu', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-linux-arm64-gnu)', + }, + '@napi-rs/keyring-linux-arm64-musl': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-linux-arm64-musl', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-linux-arm64-musl)', + }, + '@napi-rs/keyring-linux-riscv64-gnu': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-linux-riscv64-gnu', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-linux-riscv64-gnu)', + }, + '@napi-rs/keyring-linux-x64-gnu': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-linux-x64-gnu', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-linux-x64-gnu)', + }, + '@napi-rs/keyring-linux-x64-musl': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-linux-x64-musl', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-linux-x64-musl)', + }, + '@napi-rs/keyring-win32-arm64-msvc': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-win32-arm64-msvc', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-win32-arm64-msvc)', + }, + '@napi-rs/keyring-win32-ia32-msvc': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-win32-ia32-msvc', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-win32-ia32-msvc)', + }, + '@napi-rs/keyring-win32-x64-msvc': { + licenseName: 'MIT', + libraryName: '@napi-rs/keyring-win32-x64-msvc', + origin: 'npm', + owner: '(https://www.npmjs.com/package/@napi-rs/keyring-win32-x64-msvc)', + }, }; const DEPENDENCY_EXCEPTIONS: string[] = []; diff --git a/yarn.lock b/yarn.lock index 53c2bff71..59ea5a691 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1689,6 +1689,7 @@ __metadata: "@dd/factory": "workspace:*" "@dd/tools": "workspace:*" "@jridgewell/remapping": "npm:2.3.5" + "@napi-rs/keyring": "npm:1.3.0" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-esm-shim": "npm:0.1.8" @@ -1705,6 +1706,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + oauth4webapi: "npm:3.8.6" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1749,6 +1751,7 @@ __metadata: "@dd/factory": "workspace:*" "@dd/tools": "workspace:*" "@jridgewell/remapping": "npm:2.3.5" + "@napi-rs/keyring": "npm:1.3.0" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-esm-shim": "npm:0.1.8" @@ -1765,6 +1768,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + oauth4webapi: "npm:3.8.6" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1802,6 +1806,7 @@ __metadata: "@dd/factory": "workspace:*" "@dd/tools": "workspace:*" "@jridgewell/remapping": "npm:2.3.5" + "@napi-rs/keyring": "npm:1.3.0" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-esm-shim": "npm:0.1.8" @@ -1818,6 +1823,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + oauth4webapi: "npm:3.8.6" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1855,6 +1861,7 @@ __metadata: "@dd/factory": "workspace:*" "@dd/tools": "workspace:*" "@jridgewell/remapping": "npm:2.3.5" + "@napi-rs/keyring": "npm:1.3.0" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-esm-shim": "npm:0.1.8" @@ -1871,6 +1878,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + oauth4webapi: "npm:3.8.6" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1908,6 +1916,7 @@ __metadata: "@dd/factory": "workspace:*" "@dd/tools": "workspace:*" "@jridgewell/remapping": "npm:2.3.5" + "@napi-rs/keyring": "npm:1.3.0" "@rollup/plugin-babel": "npm:6.0.4" "@rollup/plugin-commonjs": "npm:28.0.1" "@rollup/plugin-esm-shim": "npm:0.1.8" @@ -1924,6 +1933,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + oauth4webapi: "npm:3.8.6" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1955,12 +1965,14 @@ __metadata: resolution: "@dd/apps-plugin@workspace:packages/plugins/apps" dependencies: "@dd/core": "workspace:*" + "@napi-rs/keyring": "npm:1.3.0" "@types/eslint-scope": "npm:3.7.7" "@types/estree": "npm:1.0.8" chalk: "npm:2.3.1" eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" jszip: "npm:3.10.1" + oauth4webapi: "npm:3.8.6" pretty-bytes: "npm:5.6.0" rollup: "npm:4.45.1" typescript: "npm:5.4.3" @@ -3327,6 +3339,135 @@ __metadata: languageName: node linkType: hard +"@napi-rs/keyring-darwin-arm64@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-darwin-arm64@npm:1.3.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@napi-rs/keyring-darwin-x64@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-darwin-x64@npm:1.3.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/keyring-freebsd-x64@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-freebsd-x64@npm:1.3.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/keyring-linux-arm-gnueabihf@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-linux-arm-gnueabihf@npm:1.3.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@napi-rs/keyring-linux-arm64-gnu@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-linux-arm64-gnu@npm:1.3.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/keyring-linux-arm64-musl@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-linux-arm64-musl@npm:1.3.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@napi-rs/keyring-linux-riscv64-gnu@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-linux-riscv64-gnu@npm:1.3.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/keyring-linux-x64-gnu@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-linux-x64-gnu@npm:1.3.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/keyring-linux-x64-musl@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-linux-x64-musl@npm:1.3.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@napi-rs/keyring-win32-arm64-msvc@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-win32-arm64-msvc@npm:1.3.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@napi-rs/keyring-win32-ia32-msvc@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-win32-ia32-msvc@npm:1.3.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@napi-rs/keyring-win32-x64-msvc@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring-win32-x64-msvc@npm:1.3.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/keyring@npm:1.3.0": + version: 1.3.0 + resolution: "@napi-rs/keyring@npm:1.3.0" + dependencies: + "@napi-rs/keyring-darwin-arm64": "npm:1.3.0" + "@napi-rs/keyring-darwin-x64": "npm:1.3.0" + "@napi-rs/keyring-freebsd-x64": "npm:1.3.0" + "@napi-rs/keyring-linux-arm-gnueabihf": "npm:1.3.0" + "@napi-rs/keyring-linux-arm64-gnu": "npm:1.3.0" + "@napi-rs/keyring-linux-arm64-musl": "npm:1.3.0" + "@napi-rs/keyring-linux-riscv64-gnu": "npm:1.3.0" + "@napi-rs/keyring-linux-x64-gnu": "npm:1.3.0" + "@napi-rs/keyring-linux-x64-musl": "npm:1.3.0" + "@napi-rs/keyring-win32-arm64-msvc": "npm:1.3.0" + "@napi-rs/keyring-win32-ia32-msvc": "npm:1.3.0" + "@napi-rs/keyring-win32-x64-msvc": "npm:1.3.0" + dependenciesMeta: + "@napi-rs/keyring-darwin-arm64": + optional: true + "@napi-rs/keyring-darwin-x64": + optional: true + "@napi-rs/keyring-freebsd-x64": + optional: true + "@napi-rs/keyring-linux-arm-gnueabihf": + optional: true + "@napi-rs/keyring-linux-arm64-gnu": + optional: true + "@napi-rs/keyring-linux-arm64-musl": + optional: true + "@napi-rs/keyring-linux-riscv64-gnu": + optional: true + "@napi-rs/keyring-linux-x64-gnu": + optional: true + "@napi-rs/keyring-linux-x64-musl": + optional: true + "@napi-rs/keyring-win32-arm64-msvc": + optional: true + "@napi-rs/keyring-win32-ia32-msvc": + optional: true + "@napi-rs/keyring-win32-x64-msvc": + optional: true + checksum: 10/bfef7fe1dfcfc29a5cf0988ce3dad06a19850351598b6a8f2b8ccd6a8f64540a80f66ae3eb018112bdabb366dbb421c026c418756ffe93d2ea19518aa87c0440 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.11 resolution: "@napi-rs/wasm-runtime@npm:0.2.11" @@ -9154,6 +9295,13 @@ __metadata: languageName: node linkType: hard +"oauth4webapi@npm:3.8.6": + version: 3.8.6 + resolution: "oauth4webapi@npm:3.8.6" + checksum: 10/980568a712c9ac6afaa35bea4b8887accfa37470fbceec6c833edad74cf82a9c981aacdbb4f270dcbc27c2a5af9b3a2d2c48ae98e51df7a278985c5f00631410 + languageName: node + linkType: hard + "object-assign@npm:^4.1.0": version: 4.1.1 resolution: "object-assign@npm:4.1.1"