diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 24bab3727..0bf163e9e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,10 @@ # Plugin owners +# Core +packages/core/src/helpers/oauth.ts @DataDog/app-builder-high-code @yoannmoinet +packages/core/src/helpers/oauth.test.ts @DataDog/app-builder-high-code @yoannmoinet + # Build Report packages/plugins/build-report @yoannmoinet 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/package.json b/packages/core/package.json index 1e7bc381f..89b0f9f30 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,7 +26,11 @@ "async-retry": "1.3.3", "chalk": "2.3.1", "glob": "11.1.0", - "json-stream-stringify": "3.1.6" + "json-stream-stringify": "3.1.6", + "oauth4webapi": "3.8.6" + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" }, "devDependencies": { "@types/async-retry": "1.4.8", diff --git a/packages/core/src/helpers/env.ts b/packages/core/src/helpers/env.ts index 88e8fa10f..7b0dbb89a 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_APPS_AUTH_METHOD +// - DATADOG_APPS_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', + 'APPS_AUTH_METHOD', 'SITE', ] as const; type ENV_KEY = (typeof OVERRIDE_VARIABLES)[number]; diff --git a/packages/core/src/helpers/oauth.test.ts b/packages/core/src/helpers/oauth.test.ts new file mode 100644 index 000000000..5475c8aad --- /dev/null +++ b/packages/core/src/helpers/oauth.test.ts @@ -0,0 +1,418 @@ +// 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 { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import nock from 'nock'; +import stripAnsi from 'strip-ansi'; + +import { + authorizeWithPKCE, + buildAuthorizationUrl, + DATAD0G_OAUTH_CLIENT_ID, + DEFAULT_OAUTH_CLIENT_ID, + DEFAULT_OAUTH_REDIRECT_URI, + DEFAULT_OAUTH_TIMEOUT_MS, + deleteOAuthTokenFromKeychain, + exchangeAuthorizationCode, + getDatadogOAuthConfig, + getOAuthToken, + readOAuthTokenFromKeychain, + resolveOAuthToken, + validateOAuthCallback, + writeOAuthTokenToKeychain, +} from './oauth'; + +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> = {}) => ({ + ...getDatadogOAuthConfig('datadoghq.com'), + clientId: 'client-id', + openBrowser: false, + timeoutMs: 1000, + ...overrides, +}); + +describe('Core - OAuth', () => { + beforeEach(() => { + mockKeyringStore.clear(); + }); + + afterEach(() => { + nock.cleanAll(); + nock.disableNetConnect(); + }); + + describe('getDatadogOAuthConfig', () => { + test('Should derive OAuth endpoints and default client ID from the site', () => { + expect(getDatadogOAuthConfig('datadoghq.eu')).toEqual({ + authorizationUrl: 'https://api.datadoghq.eu/oauth2/v1/authorize', + cacheTokens: true, + clientId: DEFAULT_OAUTH_CLIENT_ID, + openBrowser: true, + redirectUri: DEFAULT_OAUTH_REDIRECT_URI, + timeoutMs: DEFAULT_OAUTH_TIMEOUT_MS, + tokenUrl: 'https://api.datadoghq.eu/oauth2/v1/token', + }); + }); + + test('Should use the datad0g OAuth client ID for datad0g.com', () => { + const config = getDatadogOAuthConfig('datad0g.com'); + expect(config.clientId).toBe(DATAD0G_OAUTH_CLIENT_ID); + expect(config.authorizationUrl).toBe('https://api.datad0g.com/oauth2/v1/authorize'); + expect(config.tokenUrl).toBe('https://api.datad0g.com/oauth2/v1/token'); + }); + }); + + describe('resolveOAuthToken', () => { + // Uses a site not referenced elsewhere so the process-lifetime memo + // doesn't bleed into other tests. + test('Should share a single resolution across concurrent callers', async () => { + const site = 'us3.datadoghq.com'; + const config = getDatadogOAuthConfig(site); + await writeOAuthTokenToKeychain( + site, + { + accessToken: 'cached-token', + clientId: config.clientId, + expiresAt: Date.now() + 60 * 60 * 1000, + site, + }, + config, + ); + + const [first, second] = await Promise.all([ + resolveOAuthToken(site, getMockLogger()), + resolveOAuthToken(site, getMockLogger()), + ]); + + // The memo returns the same in-flight promise, so both callers + // resolve to the very same token object (not two cache reads). + expect(first).toBe(second); + expect(first.accessToken).toBe('cached-token'); + }); + }); + + 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/core/src/helpers/oauth.ts b/packages/core/src/helpers/oauth.ts new file mode 100644 index 000000000..ff6eae02b --- /dev/null +++ b/packages/core/src/helpers/oauth.ts @@ -0,0 +1,643 @@ +// 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 chalk from 'chalk'; +import { spawn } from 'child_process'; +import { createHash, randomBytes } from 'crypto'; +import http from 'http'; + +import type { Logger } from '../types'; + +const KEYRING_PACKAGE_NAME = '@napi-rs/keyring'; + +type OAuthAuthorizationServer = import('oauth4webapi').AuthorizationServer; +type OAuthClient = import('oauth4webapi').Client; +type OAuthModule = typeof import('oauth4webapi'); +type OAuthTokenEndpointResponse = import('oauth4webapi').TokenEndpointResponse; +type KeyringModule = typeof import('@napi-rs/keyring'); + +// oauth4webapi is ESM-only; dynamic import lets CommonJS consumers import these +// helpers without requiring oauth4webapi at module load time. +const loadOauth = () => import('oauth4webapi') as Promise; + +// Load through a variable specifier so bundlers leave the native keyring binary +// as a runtime require instead of inlining it. Node caches modules by specifier, +// so repeated calls reuse the same instance. +const loadKeyring = () => import(KEYRING_PACKAGE_NAME) as Promise; + +export type OAuthConfig = { + authorizationUrl: string; + cacheTokens: boolean; + clientId: string; + openBrowser: boolean; + redirectUri: string; + timeoutMs: number; + tokenUrl: string; +}; + +export const DEFAULT_OAUTH_CLIENT_ID = 'e17b9ffa-3daf-4124-ba1b-4ac8c547d506'; +export const DATAD0G_OAUTH_CLIENT_ID = 'f4bacdd2-0c8c-49f5-bf3e-a62ba3ec02e6'; +export const DEFAULT_OAUTH_REDIRECT_URI = 'http://localhost:8060'; +export const DEFAULT_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +export const OAUTH_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000; +export const OAUTH_KEYCHAIN_SERVICE = 'datadog-build-plugins:oauth'; + +type OAuthCallback = { + callbackParameters: URLSearchParams; +}; + +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_OAUTH_CLIENT_ID; + default: + return DEFAULT_OAUTH_CLIENT_ID; + } +}; + +export const getDatadogOAuthConfig = (site: string): OAuthConfig => { + const clientId = getOAuthClientId(site); + const baseOAuthUrl = `https://api.${site}/oauth2/v1`; + + return { + authorizationUrl: `${baseOAuthUrl}/authorize`, + cacheTokens: true, + clientId, + openBrowser: true, + redirectUri: DEFAULT_OAUTH_REDIRECT_URI, + timeoutMs: DEFAULT_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, log: Logger) => { + 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.once('error', (error) => { + log.warn(`Could not open browser automatically: ${getErrorMessage(error)}`); + }); + child.unref(); + } catch (error) { + log.warn(`Could not open browser automatically: ${getErrorMessage(error)}`); + } +}; + +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 })); + }); + + 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 { + const hostname = + redirectUrl.hostname === 'localhost' ? undefined : redirectUrl.hostname; + server.listen(port, 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 OAuth token in the OS credential store. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + +export const readOAuthTokenFromKeychain = async ( + site: string, + options: Pick, +): Promise => { + try { + const entry = await createOAuthCredentialEntry(site, options); + 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, +) => { + try { + const entry = await createOAuthCredentialEntry(site, options); + const credential: StoredOAuthCredential = { version: 1, token }; + await entry.setPassword(JSON.stringify(credential)); + } catch (error) { + throw secureStorageError('save', error); + } +}; + +export const deleteOAuthTokenFromKeychain = async ( + site: string, + options: Pick, +) => { + try { + const entry = await createOAuthCredentialEntry(site, options); + 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: OAuthConfig, + log: Logger, + cacheSite = token.site, +) => { + if (!options.cacheTokens) { + return; + } + + try { + await writeOAuthTokenToKeychain(cacheSite, toCachedToken(token, options.clientId), options); + log.debug('Saved Datadog OAuth token to the OS credential store.'); + } catch (error) { + log.warn( + `Could not save Datadog OAuth token to the OS credential store; continuing without a persistent cache. ${getErrorMessage(error)}`, + ); + } +}; + +const deleteOAuthToken = async (site: string, options: OAuthConfig, log: Logger): Promise => { + if (!options.cacheTokens) { + return; + } + + try { + await deleteOAuthTokenFromKeychain(site, options); + } catch (error) { + log.warn(`Could not delete cached Datadog OAuth token. ${getErrorMessage(error)}`); + } +}; + +const getCachedOAuthToken = async ( + site: string, + options: OAuthConfig, + log: Logger, +): Promise => { + if (!options.cacheTokens) { + return undefined; + } + + let cachedToken: CachedOAuthToken | undefined; + try { + cachedToken = await readOAuthTokenFromKeychain(site, options); + } catch (error) { + log.warn( + `Could not read cached Datadog OAuth token; starting browser authorization. ${getErrorMessage(error)}`, + ); + } + if (!cachedToken) { + return undefined; + } + + if (isCachedTokenValid(cachedToken)) { + log.debug('Using cached Datadog OAuth access token.'); + return fromCachedToken(cachedToken); + } + + if (!cachedToken.refreshToken) { + return undefined; + } + + try { + log.debug('Refreshing cached Datadog OAuth access token.'); + const refreshedToken = await refreshOAuthToken(site, options, cachedToken.refreshToken); + await saveOAuthToken(refreshedToken, options, log); + return refreshedToken; + } catch (error) { + log.warn( + `Cached Datadog OAuth token could not be refreshed; starting browser authorization. ${ + error instanceof Error ? error.message : String(error) + }`, + ); + await deleteOAuthToken(site, options, log); + return undefined; + } +}; + +export const authorizeWithPKCE = async ( + site: string, + options: OAuthConfig, + 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(), log); + } + + const callback = await callbackPromise; + return exchangeAuthorizationCode({ + callbackParameters: callback.callbackParameters, + clientId: options.clientId, + codeVerifier, + redirectUri: options.redirectUri, + site, + tokenUrl: options.tokenUrl, + authorizationUrl: options.authorizationUrl, + }); +}; + +export const getOAuthToken = async ( + site: string, + options: OAuthConfig, + 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); + return token; +}; + +// Memoize per site+client for the lifetime of the process so concurrent requests +// (and the sequential upload + release calls) share a single browser authorization. +const tokenCache = new Map>(); + +export const resolveOAuthToken = (site: string, log: Logger): Promise => { + const options = getDatadogOAuthConfig(site); + const key = `${site}:${options.clientId}`; + let pending = tokenCache.get(key); + if (!pending) { + pending = getOAuthToken(site, options, log).catch((error) => { + tokenCache.delete(key); + throw error; + }); + tokenCache.set(key, pending); + } + return pending; +}; diff --git a/packages/core/src/helpers/request.test.ts b/packages/core/src/helpers/request.test.ts index 2826cd785..d387d31cb 100644 --- a/packages/core/src/helpers/request.test.ts +++ b/packages/core/src/helpers/request.test.ts @@ -3,16 +3,24 @@ // Copyright 2019-Present Datadog, Inc. import { DEFAULT_SITE } from '@dd/core/constants'; +import { resolveOAuthToken } from '@dd/core/helpers/oauth'; import type { RequestOpts } from '@dd/core/types'; import { SOURCEMAPS_API_PATH, SOURCEMAPS_API_SUBDOMAIN, getIntakeUrl, } from '@dd/error-tracking-plugin/sourcemaps/sender'; +import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import nock from 'nock'; import { Readable } from 'stream'; import { createGzip } from 'zlib'; +jest.mock('@dd/core/helpers/oauth', () => ({ + resolveOAuthToken: jest.fn(), +})); + +const resolveOAuthTokenMock = jest.mocked(resolveOAuthToken); + const API_PATH = `/${SOURCEMAPS_API_PATH}`; const API_URL = `https://${SOURCEMAPS_API_SUBDOMAIN}.${DEFAULT_SITE}`; @@ -42,6 +50,8 @@ describe('Request Helpers', () => { afterEach(() => { nock.cleanAll(); + jest.restoreAllMocks(); + jest.clearAllMocks(); }); test('Should do a request', async () => { @@ -185,7 +195,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 +219,98 @@ describe('Request Helpers', () => { }), ); }); + + test('Should add bearer authentication headers when using an OAuth access token.', 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', + }), + }), + ); + }); + + test('Should not add bearer authentication headers when the OAuth access token is empty.', 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: '', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + getIntakeUrl(DEFAULT_SITE), + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.any(String), + }), + }), + ); + }); + + test('Should resolve an OAuth token and pass it to doRequest.', async () => { + resolveOAuthTokenMock.mockResolvedValue({ + accessToken: 'access-token', + site: DEFAULT_SITE, + }); + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(new Response('{}'))); + const { doOAuthRequest } = await import('@dd/core/helpers/request'); + const log = getMockLogger(); + + await doOAuthRequest({ + ...requestOpts, + site: DEFAULT_SITE, + log, + }); + + expect(resolveOAuthTokenMock).toHaveBeenCalledWith(DEFAULT_SITE, log); + expect(fetchMock).toHaveBeenCalledWith( + getIntakeUrl(DEFAULT_SITE), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + }), + }), + ); + }); + + test('Should throw when OAuth token resolution does not return an access token.', async () => { + resolveOAuthTokenMock.mockResolvedValue({ + accessToken: '', + site: DEFAULT_SITE, + }); + const fetchMock = jest + .spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve(new Response('{}'))); + const { doOAuthRequest } = await import('@dd/core/helpers/request'); + const log = getMockLogger(); + + await expect( + doOAuthRequest({ + ...requestOpts, + site: DEFAULT_SITE, + log, + }), + ).rejects.toThrow('OAuth authentication did not return an access token.'); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/src/helpers/request.ts b/packages/core/src/helpers/request.ts index 329cc259b..164245355 100644 --- a/packages/core/src/helpers/request.ts +++ b/packages/core/src/helpers/request.ts @@ -5,7 +5,9 @@ import retry from 'async-retry'; import type { RequestInit } from 'undici-types'; -import type { RequestOpts } from '../types'; +import type { Logger, RequestOpts, Site } from '../types'; + +import { resolveOAuthToken } from './oauth'; const formatErrorEntry = (e: unknown): string => { if (e === null || typeof e !== 'object') { @@ -93,8 +95,29 @@ export const createRequestData = async (options: { export const ERROR_CODES_NO_RETRY = [400, 401, 403, 404, 405, 409, 413]; export const NB_RETRIES = 5; + +export type OAuthRequestOpts = Omit & { + site: Site; + log: Logger; +}; + +export const doOAuthRequest = async ({ site, log, ...opts }: OAuthRequestOpts): Promise => { + const token = await resolveOAuthToken(site, log); + + if (!token.accessToken) { + throw new Error('OAuth authentication did not return an access token.'); + } + + return doRequest({ + ...opts, + auth: { + accessToken: token.accessToken, + }, + }); +}; + // Do a retriable fetch. -export const doRequest = (opts: RequestOpts): Promise => { +export const doRequest = async (opts: RequestOpts): Promise => { const { auth, url, method = 'GET', getData, type = 'text' } = opts; const retryOpts: retry.Options = { retries: opts.retries === 0 ? 0 : opts.retries || NB_RETRIES, @@ -117,12 +140,18 @@ export const doRequest = (opts: RequestOpts): Promise => { }; // Do auth if present. - if (auth?.apiKey) { - requestHeaders['DD-API-KEY'] = auth.apiKey; - } + if (auth && 'accessToken' in auth) { + if (auth.accessToken) { + requestHeaders.Authorization = `Bearer ${auth.accessToken}`; + } + } else { + if (auth?.apiKey) { + requestHeaders['DD-API-KEY'] = auth.apiKey; + } - if (auth?.appKey) { - requestHeaders['DD-APPLICATION-KEY'] = auth.appKey; + if (auth?.appKey) { + requestHeaders['DD-APPLICATION-KEY'] = auth.appKey; + } } if (typeof getData === 'function') { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 356ee31f3..a4c674c05 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -282,10 +282,22 @@ export type OptionsWithDefaults = Assign< export type PluginName = `datadog-${Lowercase}-plugin`; +export type ApiKeyRequestAuthOptions = { + apiKey?: string; + appKey?: string; +}; + +export type OAuthRequestAuthOptions = { + accessToken: string; +}; + +// Request-local auth. OAuth callers must resolve tokens before calling doRequest. +export type RequestAuthOptions = ApiKeyRequestAuthOptions | OAuthRequestAuthOptions; + type Data = { data?: BodyInit; headers?: Record }; export type RequestOpts = { url: string; - auth?: Pick; + auth?: RequestAuthOptions; method?: string; getData?: () => Promise | Data; type?: 'json' | 'text'; diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 4490ca1ad..7a1ca0ccb 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) + - [apps.authOverrides.method](#appsauthoverridesmethod) - [apps.identifier](#appsidentifier) - [apps.name](#appsname) @@ -31,6 +32,9 @@ apps?: { include?: string[]; identifier?: string; name?: string; + authOverrides?: { + method?: 'apiKey' | 'oauth'; + }; } ``` @@ -66,6 +70,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. +### apps.authOverrides.method + +> default: `apiKey` + +Authentication method for uploading app bundles. + +Use `apiKey` to send `DD_API_KEY`/`DD_APP_KEY` credentials from the shared `auth` config. Use `oauth` to complete a local Authorization Code + PKCE flow and upload with a short-lived bearer token instead. + +You can also set `DATADOG_APPS_AUTH_METHOD=oauth` or `DD_APPS_AUTH_METHOD=oauth`. + +When the 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 derived from `auth.site`, so it must match your Datadog data center (e.g. `datadoghq.com`, `us5.datadoghq.com`, `datadoghq.eu`). + ### 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/src/auth.test.ts b/packages/plugins/apps/src/auth.test.ts new file mode 100644 index 000000000..5269fed56 --- /dev/null +++ b/packages/plugins/apps/src/auth.test.ts @@ -0,0 +1,81 @@ +// 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 { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; +import { doOAuthRequest, doRequest } from '@dd/core/helpers/request'; +import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; + +jest.mock('@dd/core/helpers/request', () => ({ + doOAuthRequest: jest.fn(), + doRequest: jest.fn(), +})); + +const doOAuthRequestMock = jest.mocked(doOAuthRequest); +const doRequestMock = jest.mocked(doRequest); + +describe('Apps Plugin - auth', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Should build an OAuth request function', async () => { + doOAuthRequestMock.mockResolvedValue('ok'); + const log = getMockLogger(); + const doAuthenticatedRequest = getAuthenticatedRequest( + 'oauth', + { site: 'datadoghq.com' }, + log, + ); + + if (!doAuthenticatedRequest) { + throw new Error('Expected OAuth request function.'); + } + await expect( + doAuthenticatedRequest({ url: 'https://api.datadoghq.com/test' }), + ).resolves.toBe('ok'); + expect(doOAuthRequestMock).toHaveBeenCalledWith({ + url: 'https://api.datadoghq.com/test', + site: 'datadoghq.com', + log, + }); + }); + + test('Should build an API-key request function when both keys are available', async () => { + doRequestMock.mockResolvedValue('ok'); + const log = getMockLogger(); + const doAuthenticatedRequest = getAuthenticatedRequest( + 'apiKey', + { + apiKey: 'api-key', + appKey: 'app-key', + site: 'datadoghq.com', + }, + log, + ); + + if (!doAuthenticatedRequest) { + throw new Error('Expected API-key request function.'); + } + await expect( + doAuthenticatedRequest({ url: 'https://api.datadoghq.com/test' }), + ).resolves.toBe('ok'); + expect(doRequestMock).toHaveBeenCalledWith({ + url: 'https://api.datadoghq.com/test', + auth: { + apiKey: 'api-key', + appKey: 'app-key', + }, + }); + }); + + test('Should return undefined when API-key credentials are incomplete', () => { + expect( + getAuthenticatedRequest( + 'apiKey', + { apiKey: 'api-key', site: 'datadoghq.com' }, + getMockLogger(), + ), + ).toBe(undefined); + }); +}); diff --git a/packages/plugins/apps/src/auth.ts b/packages/plugins/apps/src/auth.ts new file mode 100644 index 000000000..4f0a16d8e --- /dev/null +++ b/packages/plugins/apps/src/auth.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. + +import { doOAuthRequest, doRequest } from '@dd/core/helpers/request'; +import type { AuthOptionsWithDefaults, Logger, RequestOpts } from '@dd/core/types'; + +import type { AuthMethod } from './types'; + +export type DoAuthenticatedRequest = (opts: Omit) => Promise; + +// Build the authenticated request function from the resolved method + base credentials. +// Returns undefined when API-key auth is selected but credentials are missing. +export const getAuthenticatedRequest = ( + method: AuthMethod, + auth: AuthOptionsWithDefaults, + log: Logger, +): DoAuthenticatedRequest | undefined => { + if (method === 'oauth') { + return (opts) => doOAuthRequest({ ...opts, site: auth.site, log }); + } + + if (auth.apiKey && auth.appKey) { + return (opts) => + doRequest({ + ...opts, + auth: { + apiKey: auth.apiKey, + appKey: auth.appKey, + }, + }); + } + + return undefined; +}; diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 0ef4bc662..5fdca876b 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -24,6 +24,7 @@ import path from 'path'; import { parseAst } from 'rollup/parseAst'; import { APPS_API_PATH } from './constants'; +import { handleUpload } from './vite/handle-upload'; /** Extract and assert closeBundle from the first plugin's vite hooks. */ function extractCloseBundle(plugins: PluginOptions[]) { @@ -211,9 +212,8 @@ describe('Apps Plugin - getPlugins', () => { expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { - apiKey: '123', - appKey: '123', bundlerName: 'vite', + doAuthenticatedRequest: expect.any(Function), dryRun: true, identifier: 'repo:app', name: 'test-app', @@ -230,6 +230,45 @@ describe('Apps Plugin - getPlugins', () => { expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-')); }); + test('Should pass API credentials through 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: [] }); + + await handleUpload({ + backendFunctions: [], + backendOutputs: new Map(), + context: getArgs().context, + options: { + authOverrides: { + method: 'apiKey', + }, + dryRun: false, + include: [], + }, + }); + + expect(uploader.uploadArchive).toHaveBeenCalledWith( + expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), + expect.objectContaining({ + doAuthenticatedRequest: expect.any(Function), + 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/types.ts b/packages/plugins/apps/src/types.ts index c640c6e2c..0febcebb0 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -2,7 +2,9 @@ // 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 { Assign, WithRequired } from '@dd/core/types'; + +export type AuthMethod = 'apiKey' | 'oauth'; export type AppsOptions = { enable?: boolean; @@ -10,6 +12,11 @@ export type AppsOptions = { dryRun?: boolean; identifier?: string; name?: string; + // Per-app auth overrides. `method` is scoped here rather than on the shared + // `auth` config because not every product endpoint supports OAuth. + authOverrides?: { + method?: AuthMethod; + }; }; export type AppsManifest = { @@ -26,6 +33,13 @@ export type AppsManifest = { // We don't enforce identifier, as it needs to be dynamically computed if absent. export type AppsOptionsWithDefaults = Omit< - WithRequired, + Assign< + WithRequired, + { + authOverrides: { + method: AuthMethod; + }; + } + >, 'enable' >; diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts index 58be236c7..9d2260f7a 100644 --- a/packages/plugins/apps/src/upload.test.ts +++ b/packages/plugins/apps/src/upload.test.ts @@ -5,12 +5,7 @@ import { getData, getIntakeUrl, getReleaseUrl, uploadArchive } from '@dd/apps-plugin/upload'; import { getDDEnvValue } from '@dd/core/helpers/env'; import { getFile } from '@dd/core/helpers/fs'; -import { - createRequestData, - doRequest, - getOriginHeaders, - NB_RETRIES, -} from '@dd/core/helpers/request'; +import { createRequestData, getOriginHeaders, NB_RETRIES } from '@dd/core/helpers/request'; import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; import stripAnsi from 'strip-ansi'; @@ -31,7 +26,6 @@ jest.mock('@dd/core/helpers/request', () => { return { ...actual, createRequestData: jest.fn(), - doRequest: jest.fn(), getOriginHeaders: jest.fn(), }; }); @@ -39,7 +33,6 @@ jest.mock('@dd/core/helpers/request', () => { const getDDEnvValueMock = jest.mocked(getDDEnvValue); const createRequestDataMock = jest.mocked(createRequestData); const getFileMock = jest.mocked(getFile); -const doRequestMock = jest.mocked(doRequest); const getOriginHeadersMock = jest.mocked(getOriginHeaders); describe('Apps Plugin - upload', () => { @@ -48,10 +41,10 @@ describe('Apps Plugin - upload', () => { assets: [{ absolutePath: '/tmp/a.js', relativePath: 'a.js' }], size: 1234, }; + const doAuthenticatedRequestMock = jest.fn(); const context = { - apiKey: 'api-key', - appKey: 'app-key', bundlerName: 'esbuild', + doAuthenticatedRequest: doAuthenticatedRequestMock, dryRun: false, identifier: 'repo:app', name: 'test-app', @@ -61,6 +54,7 @@ describe('Apps Plugin - upload', () => { const logger = getMockLogger(); beforeEach(() => { + doAuthenticatedRequestMock.mockReset(); getOriginHeadersMock.mockReturnValue({ 'DD-EVP-ORIGIN': 'origin', 'DD-EVP-ORIGIN-VERSION': '0.0.0', @@ -186,18 +180,16 @@ describe('Apps Plugin - upload', () => { }); describe('uploadArchive', () => { - test('Should fail when missing apiKey', async () => { + test('Should not require authentication for dry runs', async () => { const { errors, warnings } = await uploadArchive( archive, - { ...context, apiKey: undefined }, + { ...context, dryRun: true }, logger, ); - expect(errors).toHaveLength(1); - expect(errors[0].message).toBe( - 'Missing authentication token, need both app and api keys.', - ); + + expect(errors).toHaveLength(0); expect(warnings).toHaveLength(0); - expect(doRequestMock).not.toHaveBeenCalled(); + expect(doAuthenticatedRequestMock).not.toHaveBeenCalled(); }); test('Should fail when missing identifier', async () => { @@ -209,7 +201,7 @@ describe('Apps Plugin - upload', () => { expect(errors).toHaveLength(1); expect(errors[0].message).toBe('No app identifier provided'); expect(warnings).toHaveLength(0); - expect(doRequestMock).not.toHaveBeenCalled(); + expect(doAuthenticatedRequestMock).not.toHaveBeenCalled(); }); test('Should log configuration and skip request on dryRun', async () => { @@ -221,7 +213,7 @@ describe('Apps Plugin - upload', () => { expect(errors).toHaveLength(0); expect(warnings).toHaveLength(0); - expect(doRequestMock).not.toHaveBeenCalled(); + expect(doAuthenticatedRequestMock).not.toHaveBeenCalled(); expect(mockLogFn).toHaveBeenCalledWith( expect.stringContaining('Dry run enabled'), 'error', @@ -229,7 +221,7 @@ describe('Apps Plugin - upload', () => { }); test('Should upload archive and log summary', async () => { - doRequestMock.mockResolvedValue({ + doAuthenticatedRequestMock.mockResolvedValue({ version_id: 'v123', application_id: 'app123', app_builder_id: 'builder123', @@ -244,8 +236,7 @@ describe('Apps Plugin - upload', () => { plugin: 'apps', version: '1.0.0', }); - expect(doRequestMock).toHaveBeenCalledWith({ - auth: { apiKey: 'api-key', appKey: 'app-key' }, + expect(doAuthenticatedRequestMock).toHaveBeenCalledWith({ url: 'https://api.datadoghq.com/api/unstable/app-builder-code/apps/repo:app/upload', method: 'POST', type: 'json', @@ -258,8 +249,35 @@ describe('Apps Plugin - upload', () => { ); }); + test('Should upload archive using the supplied request function', async () => { + const doUploadAuthenticatedRequestMock = jest.fn().mockResolvedValue({ + version_id: 'v123', + application_id: 'app123', + app_builder_id: 'builder123', + } as any); + + const { errors, warnings } = await uploadArchive( + archive, + { + ...context, + doAuthenticatedRequest: doUploadAuthenticatedRequestMock, + }, + logger, + ); + + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + expect(doUploadAuthenticatedRequestMock).toHaveBeenCalledWith({ + 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 + doAuthenticatedRequestMock .mockResolvedValueOnce({ version_id: 'v123', application_id: 'app123', @@ -271,9 +289,8 @@ describe('Apps Plugin - upload', () => { expect(errors).toHaveLength(0); expect(warnings).toHaveLength(0); - expect(doRequestMock).toHaveBeenCalledTimes(2); - expect(doRequestMock).toHaveBeenNthCalledWith(2, { - auth: { apiKey: 'api-key', appKey: 'app-key' }, + expect(doAuthenticatedRequestMock).toHaveBeenCalledTimes(2); + expect(doAuthenticatedRequestMock).toHaveBeenNthCalledWith(2, { url: 'https://api.datadoghq.com/api/unstable/app-builder-code/apps/repo:app/release/live', method: 'PUT', type: 'json', @@ -287,7 +304,7 @@ describe('Apps Plugin - upload', () => { }); test('Should collect warnings on retries', async () => { - doRequestMock.mockImplementation(async (opts) => { + doAuthenticatedRequestMock.mockImplementation(async (opts) => { opts.onRetry?.(new Error('network'), 2); }); @@ -304,7 +321,7 @@ describe('Apps Plugin - upload', () => { }); test('Should return errors when upload fails', async () => { - doRequestMock.mockRejectedValue(new Error('boom')); + doAuthenticatedRequestMock.mockRejectedValue(new Error('boom')); const { errors } = await uploadArchive(archive, context, logger); diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts index f0ca4e32a..710feea01 100644 --- a/packages/plugins/apps/src/upload.ts +++ b/packages/plugins/apps/src/upload.ts @@ -4,12 +4,7 @@ import { getDDEnvValue } from '@dd/core/helpers/env'; import { getFile } from '@dd/core/helpers/fs'; -import { - createRequestData, - doRequest, - getOriginHeaders, - NB_RETRIES, -} from '@dd/core/helpers/request'; +import { createRequestData, getOriginHeaders, NB_RETRIES } from '@dd/core/helpers/request'; import { prettyObject } from '@dd/core/helpers/strings'; import type { Logger } from '@dd/core/types'; import chalk from 'chalk'; @@ -17,14 +12,14 @@ import prettyBytes from 'pretty-bytes'; import { Readable } from 'stream'; import type { Archive } from './archive'; +import type { DoAuthenticatedRequest } from './auth'; import { APPS_API_PATH, ARCHIVE_FILENAME } from './constants'; type DataResponse = Awaited>; export type UploadContext = { - apiKey?: string; - appKey?: string; bundlerName: string; + doAuthenticatedRequest: DoAuthenticatedRequest; dryRun: boolean; identifier: string; name: string; @@ -73,11 +68,7 @@ export const getData = export const uploadArchive = async (archive: Archive, context: UploadContext, log: Logger) => { 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 }; - } + const doAuthenticatedRequest = context.doAuthenticatedRequest; if (!context.identifier) { errors.push(new Error('No app identifier provided')); @@ -114,8 +105,7 @@ Would have uploaded ${summary}`, } try { - const response: any = await doRequest({ - auth: { apiKey: context.apiKey, appKey: context.appKey }, + const response: any = await doAuthenticatedRequest({ url: intakeUrl, method: 'POST', type: 'json', @@ -139,8 +129,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 }, + await doAuthenticatedRequest({ 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..d9ad0c512 100644 --- a/packages/plugins/apps/src/validate.test.ts +++ b/packages/plugins/apps/src/validate.test.ts @@ -9,6 +9,9 @@ describe('Apps Plugin - validateOptions', () => { test('Should set defaults when nothing is provided', () => { const result = validateOptions({}); expect(result).toEqual({ + authOverrides: { + method: 'apiKey', + }, dryRun: true, include: [], identifier: undefined, @@ -59,11 +62,35 @@ describe('Apps Plugin - validateOptions', () => { }); expect(result).toEqual({ + authOverrides: { + method: 'apiKey', + }, dryRun: true, include: ['public/**/*', 'dist/**/*'], identifier: 'my-app', name: undefined, }); }); + + test('Should enable OAuth method when configured', () => { + const result = validateOptions({ + apps: { + enable: true, + authOverrides: { method: 'oauth' }, + }, + }); + + expect(result.authOverrides.method).toBe('oauth'); + }); + + test('Should allow env vars to opt into OAuth', () => { + process.env.DATADOG_APPS_AUTH_METHOD = 'oauth'; + try { + const result = validateOptions({ apps: {} }); + expect(result.authOverrides.method).toBe('oauth'); + } finally { + delete process.env.DATADOG_APPS_AUTH_METHOD; + } + }); }); }); diff --git a/packages/plugins/apps/src/validate.ts b/packages/plugins/apps/src/validate.ts index 38e75a61e..4ceac46b3 100644 --- a/packages/plugins/apps/src/validate.ts +++ b/packages/plugins/apps/src/validate.ts @@ -6,15 +6,36 @@ import { getDDEnvValue } from '@dd/core/helpers/env'; import type { Options } from '@dd/core/types'; import { CONFIG_KEY } from './constants'; -import type { AppsOptions, AppsOptionsWithDefaults } from './types'; +import type { AppsOptions, AppsOptionsWithDefaults, AuthMethod } 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(`apps.authOverrides.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('APPS_AUTH_METHOD') || resolvedOptions.authOverrides?.method, + ) || 'apiKey'; return { include: resolvedOptions.include || [], dryRun: resolvedOptions.dryRun ?? !getDDEnvValue('APPS_UPLOAD_ASSETS'), identifier: resolvedOptions.identifier?.trim(), name: resolvedOptions.name?.trim() || options.metadata?.name?.trim(), + authOverrides: { + method, + }, }; }; diff --git a/packages/plugins/apps/src/vite/handle-upload.ts b/packages/plugins/apps/src/vite/handle-upload.ts index 9519f363a..01cb27d48 100644 --- a/packages/plugins/apps/src/vite/handle-upload.ts +++ b/packages/plugins/apps/src/vite/handle-upload.ts @@ -12,6 +12,8 @@ import path from 'path'; import { createArchive } from '../archive'; import type { Asset } from '../assets'; import { collectAssets } from '../assets'; +import { getAuthenticatedRequest } from '../auth'; +import type { DoAuthenticatedRequest } from '../auth'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; import { PLUGIN_NAME } from '../constants'; @@ -22,6 +24,16 @@ import { uploadArchive } from '../upload'; const yellow = chalk.yellow.bold; const red = chalk.red.bold; const MANIFEST_FILE_NAME = 'manifest.json'; +const MISSING_AUTHENTICATION_ERROR = + 'Missing authentication, need either OAuth (apps.authOverrides.method: "oauth") or both api and app keys.'; + +const doMissingAuthenticationRequest: DoAuthenticatedRequest = async () => { + throw new Error(MISSING_AUTHENTICATION_ERROR); +}; + +const doDryRunAuthenticatedRequest: DoAuthenticatedRequest = async () => { + throw new Error('Dry run should not perform authenticated requests.'); +}; export interface HandleUploadOptions { backendOutputs: Map; @@ -139,12 +151,16 @@ Either: archiveDir = path.dirname(archive.archivePath); const uploadTimer = log.time('upload assets'); + const doAuthenticatedRequest = + (options.dryRun + ? doDryRunAuthenticatedRequest + : getAuthenticatedRequest(options.authOverrides.method, auth, log)) || + doMissingAuthenticationRequest; const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive( archive, { - apiKey: auth.apiKey, - appKey: auth.appKey, bundlerName, + doAuthenticatedRequest, dryRun: options.dryRun, identifier, name, diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index d53ed20c1..d4e5d4b11 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -90,8 +90,20 @@ const defaultOptions = { }), options: { enable: true, + authOverrides: { + 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 606c20fe7..8902bba91 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -58,6 +58,7 @@ "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", @@ -104,5 +105,8 @@ "magic-string": { "optional": true } + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" } } diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index c9d1a64b1..df8c3039e 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -61,6 +61,7 @@ "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", @@ -107,5 +108,8 @@ "magic-string": { "optional": true } + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" } } diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index 0b9f54888..b3d20022d 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -58,6 +58,7 @@ "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", @@ -104,5 +105,8 @@ "magic-string": { "optional": true } + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" } } diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index 7649a2897..84acf1fbe 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -58,6 +58,7 @@ "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", @@ -104,5 +105,8 @@ "magic-string": { "optional": true } + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" } } diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 2fbfed064..71829bb30 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -58,6 +58,7 @@ "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", @@ -104,5 +105,8 @@ "magic-string": { "optional": true } + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.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 495ca9fae..7be28e355 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" @@ -1719,6 +1721,9 @@ __metadata: "@babel/types": ^7.24.5 esbuild: ">=0.x" magic-string: ^0.30.0 + dependenciesMeta: + "@napi-rs/keyring": + optional: true peerDependenciesMeta: "@babel/parser": optional: true @@ -1749,6 +1754,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 +1771,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" @@ -1779,6 +1786,9 @@ __metadata: "@babel/types": ^7.24.5 magic-string: ^0.30.0 rollup: ">= 3.x < 5.x" + dependenciesMeta: + "@napi-rs/keyring": + optional: true peerDependenciesMeta: "@babel/parser": optional: true @@ -1802,6 +1812,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 +1829,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" @@ -1832,6 +1844,9 @@ __metadata: "@babel/types": ^7.24.5 "@rspack/core": 1.x magic-string: ^0.30.0 + dependenciesMeta: + "@napi-rs/keyring": + optional: true peerDependenciesMeta: "@babel/parser": optional: true @@ -1855,6 +1870,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 +1887,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" @@ -1885,6 +1902,9 @@ __metadata: "@babel/types": ^7.24.5 magic-string: ^0.30.0 vite: ">= 5.x <= 7.x" + dependenciesMeta: + "@napi-rs/keyring": + optional: true peerDependenciesMeta: "@babel/parser": optional: true @@ -1908,6 +1928,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 +1945,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" @@ -1938,6 +1960,9 @@ __metadata: "@babel/types": ^7.24.5 magic-string: ^0.30.0 webpack: ">= 5.x < 6.x" + dependenciesMeta: + "@napi-rs/keyring": + optional: true peerDependenciesMeta: "@babel/parser": optional: true @@ -1978,6 +2003,7 @@ __metadata: version: 0.0.0-use.local resolution: "@dd/core@workspace:packages/core" dependencies: + "@napi-rs/keyring": "npm:1.3.0" "@types/async-retry": "npm:1.4.8" "@types/chalk": "npm:2.2.0" "@types/node": "npm:^20" @@ -1986,8 +2012,12 @@ __metadata: esbuild: "npm:0.25.8" glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" + oauth4webapi: "npm:3.8.6" typescript: "npm:5.4.3" unplugin: "npm:2.3.11" + dependenciesMeta: + "@napi-rs/keyring": + optional: true languageName: unknown linkType: soft @@ -3327,6 +3357,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 +9313,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"