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..30107ec2b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,10 +23,12 @@ "watch": "tsc -w" }, "dependencies": { + "@napi-rs/keyring": "1.3.0", "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" }, "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..82aa84e8d --- /dev/null +++ b/packages/core/src/helpers/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/core/helpers/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/core/src/helpers/oauth.ts b/packages/core/src/helpers/oauth.ts new file mode 100644 index 000000000..20c220720 --- /dev/null +++ b/packages/core/src/helpers/oauth.ts @@ -0,0 +1,615 @@ +// 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 type { + AuthorizationServer as OAuthAuthorizationServer, + Client as OAuthClient, + TokenEndpointResponse as OAuthTokenEndpointResponse, +} from 'oauth4webapi'; + +type OAuthModule = typeof import('oauth4webapi'); +type KeyringModule = typeof import('@napi-rs/keyring'); + +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; +}; + +export type OAuthConfig = { + authorizationUrl: string; + cacheTokens: boolean; + clientId: string; + openBrowser: boolean; + redirectUri: string; + timeoutMs: number; + tokenUrl: 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 OAUTH_PACKAGE_NAME = 'oauth4webapi'; +const KEYRING_PACKAGE_NAME = '@napi-rs/keyring'; + +const base64Url = (buffer: Buffer) => buffer.toString('base64url'); + +const memoizeAsync = (load: () => Promise) => { + let promise: Promise | undefined; + + return () => { + if (!promise) { + promise = load().catch((error) => { + promise = undefined; + throw error; + }); + } + return promise; + }; +}; + +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. +const loadKeyring = memoizeAsync(() => import(KEYRING_PACKAGE_NAME) as Promise); + +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): OAuthConfig => { + 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 })); + }); + + 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: OAuthConfig, + 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: OAuthConfig): Promise => { + if (!options.cacheTokens) { + return; + } + + await deleteOAuthTokenFromKeychain(site, options); +}; + +const getCachedOAuthToken = async ( + site: string, + options: OAuthConfig, + 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: 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()); + } + + 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; +}; diff --git a/packages/core/src/helpers/request-auth.test.ts b/packages/core/src/helpers/request-auth.test.ts index 10d99d0e2..fa5cf14ed 100644 --- a/packages/core/src/helpers/request-auth.test.ts +++ b/packages/core/src/helpers/request-auth.test.ts @@ -3,16 +3,32 @@ // Copyright 2019-Present Datadog, Inc. import { DEFAULT_SITE } from '@dd/core/constants'; +import { getOAuthToken } from '@dd/core/helpers/oauth'; import { DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE, + DEFAULT_OAUTH_AND_API_AUTH_MISSING_AUTH_MESSAGE, + DEFAULT_OAUTH_AUTH_MISSING_AUTH_MESSAGE, MissingRequestAuthError, + authMethodIsOauth, hasValidAppApiKey, withApiAuth, withBaseUrl, + withOAuthAndApiAuth, + withOAuthAuth, } from '@dd/core/helpers/request-auth'; import type { AuthOptionsWithDefaults } from '@dd/core/types'; import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; +jest.mock('@dd/core/helpers/oauth', () => { + const actual = jest.requireActual('@dd/core/helpers/oauth'); + return { + ...actual, + getOAuthToken: jest.fn(), + }; +}); + +const getOAuthTokenMock = jest.mocked(getOAuthToken); + describe('Core - request auth', () => { const auth: AuthOptionsWithDefaults = { apiKey: 'api-key', @@ -22,9 +38,16 @@ describe('Core - request auth', () => { const log = getMockLogger(); beforeEach(() => { + getOAuthTokenMock.mockReset(); mockLogFn.mockClear(); }); + test('Should identify OAuth auth methods', () => { + expect(authMethodIsOauth('oauth')).toBe(true); + expect(authMethodIsOauth('apiKey')).toBe(false); + expect(authMethodIsOauth()).toBe(false); + }); + test('Should identify valid APP/API key auth', () => { expect(hasValidAppApiKey(auth)).toBe(true); expect(hasValidAppApiKey({ apiKey: 'api-key' })).toBe(false); @@ -45,6 +68,125 @@ describe('Core - request auth', () => { }); }); + test('Should lazily fetch OAuth token and inject bearer auth before calling request', async () => { + getOAuthTokenMock.mockResolvedValue({ + accessToken: 'access-token', + site: DEFAULT_SITE, + }); + const request = jest.fn().mockResolvedValue('ok'); + const requestWithAuth = withOAuthAuth({ auth, log })(request); + + expect(getOAuthTokenMock).not.toHaveBeenCalled(); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).resolves.toBe( + 'ok', + ); + + expect(getOAuthTokenMock).toHaveBeenCalledTimes(1); + expect(getOAuthTokenMock).toHaveBeenCalledWith( + DEFAULT_SITE, + expect.objectContaining({ + authorizationUrl: `https://api.${DEFAULT_SITE}/oauth2/v1/authorize`, + tokenUrl: `https://api.${DEFAULT_SITE}/oauth2/v1/token`, + }), + log, + ); + expect(request).toHaveBeenCalledWith({ + url: 'https://api.datadoghq.com/test', + auth: { type: 'bearer', accessToken: 'access-token' }, + }); + }); + + test('Should reuse in-flight OAuth token requests', async () => { + getOAuthTokenMock.mockResolvedValue({ + accessToken: 'access-token', + site: DEFAULT_SITE, + }); + const request = jest.fn().mockResolvedValue('ok'); + const requestWithAuth = withOAuthAuth({ auth, log })(request); + + await Promise.all([ + requestWithAuth({ url: 'https://api.datadoghq.com/one' }), + requestWithAuth({ url: 'https://api.datadoghq.com/two' }), + ]); + + expect(getOAuthTokenMock).toHaveBeenCalledTimes(1); + }); + + test('Should resolve OAuth token again for sequential requests', async () => { + getOAuthTokenMock.mockResolvedValue({ + accessToken: 'access-token', + site: DEFAULT_SITE, + }); + const request = jest.fn().mockResolvedValue('ok'); + const requestWithAuth = withOAuthAuth({ auth, log })(request); + + await requestWithAuth({ url: 'https://api.datadoghq.com/one' }); + await requestWithAuth({ url: 'https://api.datadoghq.com/two' }); + + expect(getOAuthTokenMock).toHaveBeenCalledTimes(2); + }); + + test('Should reset OAuth token cache after authorization failure', async () => { + getOAuthTokenMock.mockRejectedValueOnce(new Error('oauth failed')).mockResolvedValueOnce({ + accessToken: 'access-token', + site: DEFAULT_SITE, + }); + const request = jest.fn().mockResolvedValue('ok'); + const requestWithAuth = withOAuthAuth({ auth, log })(request); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).rejects.toThrow( + 'oauth failed', + ); + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).resolves.toBe( + 'ok', + ); + + expect(getOAuthTokenMock).toHaveBeenCalledTimes(2); + }); + + test('Should select API auth from the combined wrapper', async () => { + const request = jest.fn().mockResolvedValue('ok'); + const requestWithAuth = withOAuthAndApiAuth({ + auth, + log, + method: 'apiKey', + })(request); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).resolves.toBe( + 'ok', + ); + + expect(getOAuthTokenMock).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledWith({ + url: 'https://api.datadoghq.com/test', + auth: { apiKey: 'api-key', appKey: 'app-key' }, + }); + }); + + test('Should select OAuth auth from the combined wrapper', async () => { + getOAuthTokenMock.mockResolvedValue({ + accessToken: 'access-token', + site: DEFAULT_SITE, + }); + const request = jest.fn().mockResolvedValue('ok'); + const requestWithAuth = withOAuthAndApiAuth({ + auth, + log, + method: 'oauth', + })(request); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).resolves.toBe( + 'ok', + ); + + expect(getOAuthTokenMock).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith({ + url: 'https://api.datadoghq.com/test', + auth: { type: 'bearer', accessToken: 'access-token' }, + }); + }); + test('Should warn and reject when API key auth is selected without required credentials', async () => { const request = jest.fn(); const requestWithAuth = withApiAuth({ @@ -91,6 +233,42 @@ describe('Core - request auth', () => { expect(request).not.toHaveBeenCalled(); }); + test('Should use the OAuth auth default missing auth message', async () => { + const request = jest.fn(); + const requestWithAuth = withOAuthAuth({ + auth: {} as Pick, + log, + })(request); + + expect(mockLogFn).toHaveBeenCalledWith(DEFAULT_OAUTH_AUTH_MISSING_AUTH_MESSAGE, 'warn'); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).rejects.toThrow( + DEFAULT_OAUTH_AUTH_MISSING_AUTH_MESSAGE, + ); + expect(getOAuthTokenMock).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + }); + + test('Should use the combined auth default missing auth message', async () => { + const request = jest.fn(); + const requestWithAuth = withOAuthAndApiAuth({ + auth: { site: DEFAULT_SITE }, + log, + method: 'apiKey', + })(request); + + expect(mockLogFn).not.toHaveBeenCalled(); + + await expect(requestWithAuth({ url: 'https://api.datadoghq.com/test' })).rejects.toThrow( + DEFAULT_OAUTH_AND_API_AUTH_MISSING_AUTH_MESSAGE, + ); + expect(mockLogFn).toHaveBeenCalledWith( + DEFAULT_OAUTH_AND_API_AUTH_MISSING_AUTH_MESSAGE, + 'warn', + ); + expect(request).not.toHaveBeenCalled(); + }); + test('Should prefix relative request URLs with a base URL', async () => { const request = jest.fn().mockResolvedValue('ok'); const requestWithBaseUrl = withBaseUrl('https://api.datadoghq.com')(request); diff --git a/packages/core/src/helpers/request-auth.ts b/packages/core/src/helpers/request-auth.ts index fb89506a5..79ccc4dc4 100644 --- a/packages/core/src/helpers/request-auth.ts +++ b/packages/core/src/helpers/request-auth.ts @@ -2,7 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { AuthOptionsWithDefaults, Logger, RequestAuthOptions, RequestOpts } from '../types'; +import type { + AuthMethod, + AuthOptionsWithDefaults, + Logger, + RequestAuthOptions, + RequestOpts, +} from '../types'; + +import { getOAuthConfig, getOAuthToken } from './oauth'; export type RequestOptsWithoutAuth = Omit; export type RequestFunction = (opts: RequestOpts) => Promise; @@ -12,6 +20,10 @@ export type AuthenticatedRequestFunction = ((opts: RequestOptsWithoutAuth) => export const DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE = 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY.'; +export const DEFAULT_OAUTH_AUTH_MISSING_AUTH_MESSAGE = + 'OAuth auth is not configured. Set a Datadog site before authorizing OAuth requests.'; +export const DEFAULT_OAUTH_AND_API_AUTH_MISSING_AUTH_MESSAGE = + 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY or use OAuth auth.'; export class MissingRequestAuthError extends Error { constructor(message = DEFAULT_API_AUTH_MISSING_AUTH_MESSAGE) { @@ -19,6 +31,8 @@ export class MissingRequestAuthError extends Error { } } +export const authMethodIsOauth = (method?: AuthMethod) => method === 'oauth'; + export const hasValidAppApiKey = (auth: Pick) => Boolean(auth.apiKey && auth.appKey); @@ -74,3 +88,71 @@ export const withApiAuth = requestWithAuth.assertAuthConfigured = assertAuthConfigured; return requestWithAuth; }; + +export const withOAuthAuth = + ({ + auth, + log, + missingAuthMessage = DEFAULT_OAUTH_AUTH_MISSING_AUTH_MESSAGE, + }: { + auth: Pick; + log: Logger; + missingAuthMessage?: string; + }) => + (request: RequestFunction): AuthenticatedRequestFunction => { + let oauthRequestAuthPromise: Promise | undefined; + + if (!auth.site) { + log.warn(missingAuthMessage); + } + + const assertAuthConfigured = () => { + if (!auth.site) { + throw new MissingRequestAuthError(missingAuthMessage); + } + }; + + const authorizeOAuthRequest = async (): Promise => { + assertAuthConfigured(); + const authTimer = log.time('authorize OAuth request'); + try { + const token = await getOAuthToken(auth.site, getOAuthConfig(auth.site), log); + return { type: 'bearer', accessToken: token.accessToken }; + } finally { + authTimer.end(); + } + }; + + const requestWithAuth = async (opts: RequestOptsWithoutAuth) => { + try { + assertAuthConfigured(); + if (!oauthRequestAuthPromise) { + oauthRequestAuthPromise = authorizeOAuthRequest(); + } + const requestAuth = await oauthRequestAuthPromise; + return request({ ...opts, auth: requestAuth }); + } finally { + oauthRequestAuthPromise = undefined; + } + }; + + requestWithAuth.assertAuthConfigured = assertAuthConfigured; + return requestWithAuth; + }; + +export const withOAuthAndApiAuth = + ({ + auth, + log, + method, + missingAuthMessage = DEFAULT_OAUTH_AND_API_AUTH_MISSING_AUTH_MESSAGE, + }: { + auth: AuthOptionsWithDefaults; + log: Logger; + method: AuthMethod; + missingAuthMessage?: string; + }) => + (request: RequestFunction): AuthenticatedRequestFunction => + authMethodIsOauth(method) + ? withOAuthAuth({ auth, log, missingAuthMessage })(request) + : withApiAuth({ auth, log, missingAuthMessage })(request); diff --git a/packages/core/src/helpers/request.test.ts b/packages/core/src/helpers/request.test.ts index 2826cd785..880d6a15b 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,28 @@ 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: { + type: 'bearer', + 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..893fb9ec0 100644 --- a/packages/core/src/helpers/request.ts +++ b/packages/core/src/helpers/request.ts @@ -64,6 +64,23 @@ export type RequestData = { export type FormBuilder = () => Promise | FormData; +export const getAuthHeaders = (auth: RequestOpts['auth']): Record => { + if (auth?.type === 'bearer') { + return { + Authorization: `Bearer ${auth.accessToken}`, + }; + } + + const headers: Record = {}; + if (auth?.apiKey) { + headers['DD-API-KEY'] = auth.apiKey; + } + if (auth?.appKey) { + headers['DD-APPLICATION-KEY'] = auth.appKey; + } + return headers; +}; + export const createRequestData = async (options: { getForm: FormBuilder; defaultHeaders: Record; @@ -114,17 +131,9 @@ export const doRequest = (opts: RequestOpts): Promise => { }; let requestHeaders: RequestInit['headers'] = { 'X-Datadog-Origin': 'build-plugins', + ...getAuthHeaders(auth), }; - // Do auth if present. - if (auth?.apiKey) { - requestHeaders['DD-API-KEY'] = auth.apiKey; - } - - if (auth?.appKey) { - requestHeaders['DD-APPLICATION-KEY'] = auth.appKey; - } - if (typeof getData === 'function') { const { data, headers } = await getData(); requestInit.body = data; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 68f85d550..3e1da90cb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -245,6 +245,8 @@ 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; @@ -281,7 +283,16 @@ export type OptionsWithDefaults = Assign< export type PluginName = `datadog-${Lowercase}-plugin`; type Data = { data?: BodyInit; headers?: Record }; -export type RequestAuthOptions = Pick; +export type RequestAuthOptions = + | { + type: 'bearer'; + accessToken: string; + } + | { + type?: 'apiKey'; + apiKey?: string; + appKey?: string; + }; export type RequestOpts = { url: string; diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 4490ca1ad..9dcd9fd5f 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.authOverride.method](#appsauthoverridemethod) - [apps.identifier](#appsidentifier) - [apps.name](#appsname) @@ -25,7 +26,16 @@ A plugin to upload assets to Datadog's storage ## Configuration ```ts +auth?: { + apiKey?: string; + appKey?: string; + site?: string; +} + apps?: { + authOverride?: { + method?: 'apiKey' | 'oauth'; + }; dryRun?: boolean; enable?: boolean; include?: string[]; @@ -66,6 +76,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.authOverride.method + +> default: `apiKey` + +Authentication method override for Apps API calls. + +Use `apiKey` to send `DD_API_KEY`/`DD_APP_KEY` credentials. Use `oauth` to complete a local Authorization Code + PKCE flow and call Apps APIs with a short-lived bearer token instead. + +You can also set `DATADOG_APPS_AUTH_METHOD=oauth` or `DD_APPS_AUTH_METHOD=oauth`. + +When `apps.authOverride.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/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index bc8515501..62937513e 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -9,6 +9,8 @@ import * as uploader from '@dd/apps-plugin/upload'; import { getPlugins } from '@dd/apps-plugin'; import { DEFAULT_SITE } from '@dd/core/constants'; import * as fsHelpers from '@dd/core/helpers/fs'; +import * as oauth from '@dd/core/helpers/oauth'; +import type { AuthenticatedRequestFunction } from '@dd/core/helpers/request-auth'; import { InjectPosition } from '@dd/core/types'; import type { PluginOptions } from '@dd/core/types'; import { @@ -24,6 +26,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,11 +215,11 @@ describe('Apps Plugin - getPlugins', () => { expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { - appBaseUrl: `https://app.${DEFAULT_SITE}`, bundlerName: 'vite', dryRun: true, identifier: 'repo:app', name: 'test-app', + appBaseUrl: `https://app.${DEFAULT_SITE}`, request: expect.any(Function), version: 'FAKE_VERSION', }, @@ -229,6 +233,98 @@ describe('Apps Plugin - getPlugins', () => { expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-')); }); + test('Should pass an OAuth-backed request handler 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(uploader, 'uploadArchive').mockResolvedValue({ errors: [], warnings: [] }); + + const closeBundle = extractCloseBundle( + getPlugins( + getGetPluginsArg( + { + auth: { + site: DEFAULT_SITE, + }, + apps: { + authOverride: { + method: 'oauth', + }, + dryRun: false, + }, + }, + { + bundler: { ...getMockBundler({ name: 'vite' }), outDir }, + buildRoot, + git: getRepositoryDataMock({ remote: 'git@github.com:org/repo.git' }), + }, + ), + ), + ); + await closeBundle(); + + expect(uploader.uploadArchive).toHaveBeenCalledWith( + expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), + expect.objectContaining({ + appBaseUrl: `https://app.${DEFAULT_SITE}`, + request: expect.any(Function), + }), + 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: [], + method: 'apiKey', + } as unknown as AppsOptionsWithDefaults, + request: Object.assign(jest.fn(), { + assertAuthConfigured: jest.fn(), + }) as jest.Mock & AuthenticatedRequestFunction, + }); + + expect(getOAuthTokenSpy).not.toHaveBeenCalled(); + expect(uploader.uploadArchive).toHaveBeenCalledWith( + expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), + expect.objectContaining({ + appBaseUrl: `https://app.${DEFAULT_SITE}`, + request: expect.any(Function), + }), + 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..a63ee2441 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -2,7 +2,7 @@ // 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 AppsOptions = { enable?: boolean; @@ -10,6 +10,9 @@ export type AppsOptions = { dryRun?: boolean; identifier?: string; name?: string; + authOverride?: { + method?: AuthMethod; + }; }; export type AppsManifest = { @@ -27,5 +30,7 @@ export type AppsManifest = { // We don't enforce identifier, as it needs to be dynamically computed if absent. export type AppsOptionsWithDefaults = Omit< WithRequired, - 'enable' ->; + 'enable' | 'authOverride' +> & { + method: AuthMethod; +}; diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts index 2ede4ec99..f69e9475f 100644 --- a/packages/plugins/apps/src/upload.test.ts +++ b/packages/plugins/apps/src/upload.test.ts @@ -167,7 +167,7 @@ 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(requestMock).not.toHaveBeenCalled(); diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts index 5c65b2074..8a12f7780 100644 --- a/packages/plugins/apps/src/upload.ts +++ b/packages/plugins/apps/src/upload.ts @@ -104,7 +104,11 @@ Would have uploaded ${summary}`, } if (!context.request) { - errors.push(new Error('Missing authentication token, need both app and api keys.')); + errors.push( + new Error( + 'Missing authentication token, need either an OAuth access token or both app and api keys.', + ), + ); return { errors, warnings }; } diff --git a/packages/plugins/apps/src/validate.test.ts b/packages/plugins/apps/src/validate.test.ts index a660f1662..753a5904d 100644 --- a/packages/plugins/apps/src/validate.test.ts +++ b/packages/plugins/apps/src/validate.test.ts @@ -12,6 +12,7 @@ describe('Apps Plugin - validateOptions', () => { dryRun: true, include: [], identifier: undefined, + method: 'apiKey', name: undefined, }); }); @@ -62,8 +63,32 @@ describe('Apps Plugin - validateOptions', () => { dryRun: true, include: ['public/**/*', 'dist/**/*'], identifier: 'my-app', + method: 'apiKey', name: undefined, }); }); + + test('Should enable OAuth method when configured', () => { + const result = validateOptions({ + apps: { + authOverride: { + method: 'oauth', + }, + enable: true, + }, + }); + + expect(result.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.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..f5f78d339 100644 --- a/packages/plugins/apps/src/validate.ts +++ b/packages/plugins/apps/src/validate.ts @@ -3,15 +3,34 @@ // Copyright 2019-Present Datadog, Inc. 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 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(`apps.authOverride.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')) ?? + resolveAuthMethod(resolvedOptions.authOverride?.method) ?? + 'apiKey'; return { + method, include: resolvedOptions.include || [], dryRun: resolvedOptions.dryRun ?? !getDDEnvValue('APPS_UPLOAD_ASSETS'), identifier: resolvedOptions.identifier?.trim(), diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index d53ed20c1..312e2d1da 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -90,6 +90,7 @@ const defaultOptions = { }), options: { enable: true, + method: 'apiKey' as const, include: [], dryRun: true, }, diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 709d33c9a..7c25c1285 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -3,7 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import { rm } from '@dd/core/helpers/fs'; -import { withApiAuth, withBaseUrl } from '@dd/core/helpers/request-auth'; +import { withBaseUrl, withOAuthAndApiAuth } from '@dd/core/helpers/request-auth'; import { doRequest } from '@dd/core/helpers/request'; import type { GlobalContext, PluginOptions } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; @@ -102,10 +102,12 @@ export const getVitePlugin = ({ const log = context.getLogger(PLUGIN_NAME); const { auth, buildRoot } = context; - const doApiRequest = withApiAuth({ + const doApiRequest = withOAuthAndApiAuth({ auth, log, - missingAuthMessage: 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY.', + method: options.method, + missingAuthMessage: + 'Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY or use apps.authOverride.method oauth.', })(withBaseUrl(`https://api.${auth.site}`)(doRequest)); context.inject({ 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..757db7b91 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" @@ -1978,6 +1988,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,6 +1997,7 @@ __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" languageName: unknown @@ -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"