Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
14 changes: 14 additions & 0 deletions LICENSES-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/helpers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const yellow = chalk.bold.yellow;
// - DATADOG_APPS_UPLOAD_ASSETS
// - DD_APPS_VERSION_NAME
// - DATADOG_APPS_VERSION_NAME
// - DD_AUTH_METHOD
// - DATADOG_AUTH_METHOD
// - DD_SITE
// - DATADOG_SITE
const OVERRIDE_VARIABLES = [
Expand All @@ -31,6 +33,7 @@ const OVERRIDE_VARIABLES = [
'APPS_INTAKE_URL',
'APPS_UPLOAD_ASSETS',
'APPS_VERSION_NAME',
'AUTH_METHOD',
'SITE',
] as const;
type ENV_KEY = (typeof OVERRIDE_VARIABLES)[number];
Expand Down
24 changes: 23 additions & 1 deletion packages/core/src/helpers/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('{}')));
Expand All @@ -209,5 +209,27 @@ describe('Request Helpers', () => {
}),
);
});

test('Should add bearer authentication headers when using OAuth.', async () => {
const fetchMock = jest
.spyOn(global, 'fetch')
.mockImplementation(() => Promise.resolve(new Response('{}')));
const { doRequest } = await import('@dd/core/helpers/request');
await doRequest({
...requestOpts,
auth: {
accessToken: 'access-token',
},
});

expect(fetchMock).toHaveBeenCalledWith(
getIntakeUrl(DEFAULT_SITE),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer access-token',
}),
}),
);
});
});
});
4 changes: 4 additions & 0 deletions packages/core/src/helpers/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ export const doRequest = <T>(opts: RequestOpts): Promise<T> => {
};

// Do auth if present.
if (auth?.accessToken) {
requestHeaders.Authorization = `Bearer ${auth.accessToken}`;
}

if (auth?.apiKey) {
requestHeaders['DD-API-KEY'] = auth.apiKey;
}
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,13 @@ export type GetWrappedPlugins = (arg: GetPluginsArg) => (PluginOptions | CustomP
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';

export type Site = (typeof SITES)[number];
export type AuthMethod = 'apiKey' | 'oauth';

export type AuthOptions = {
apiKey?: string;
appKey?: string;
accessToken?: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why's this user facing? Not sure I understand the need for it, from the user's perspective.

method?: AuthMethod;
site?: string;
};

Expand Down Expand Up @@ -283,7 +287,7 @@ export type PluginName = `datadog-${Lowercase<string>}-plugin`;
type Data = { data?: BodyInit; headers?: Record<string, string> };
export type RequestOpts = {
url: string;
auth?: Pick<AuthOptions, 'apiKey' | 'appKey'>;
auth?: Pick<AuthOptions, 'apiKey' | 'appKey' | 'accessToken'>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this should be more of the form:

Suggested change
auth?: Pick<AuthOptions, 'apiKey' | 'appKey' | 'accessToken'>;
auth?: Assign<Pick<AuthOptions, 'apiKey' | 'appKey'>, { accessToken: string }>;

So accessToken isn't leaked into user facing API (unless we actually need it there).

method?: string;
getData?: () => Promise<Data> | Data;
type?: 'json' | 'text';
Expand Down
6 changes: 6 additions & 0 deletions packages/factory/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const validateOptions = (options: Options = {}): OptionsWithDefaults => {
const envSite = resolveSite(envRaw, 'DATADOG_SITE/DD_SITE', errors);

const auth: AuthOptionsWithDefaults = {
method: options.auth?.method,
site: envSite ?? resolveSite(options.auth?.site, 'auth.site', errors) ?? DEFAULT_SITE,
};

Expand All @@ -75,6 +76,11 @@ export const validateOptions = (options: Options = {}): OptionsWithDefaults => {
enumerable: false,
});

Object.defineProperty(auth, 'accessToken', {
value: options.auth?.accessToken,
enumerable: false,
});

return {
enableGit: true,
logLevel: 'warn',
Expand Down
24 changes: 24 additions & 0 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,21 @@ A plugin to upload assets to Datadog's storage
- [apps.dryRun](#appsdryrun)
- [apps.enable](#appsenable)
- [apps.include](#appsinclude)
- [auth.method](#authmethod)
- [apps.identifier](#appsidentifier)
- [apps.name](#appsname)
<!-- #toc -->

## Configuration

```ts
auth?: {
method?: 'apiKey' | 'oauth';
apiKey?: string;
appKey?: string;
site?: string;
}

apps?: {
dryRun?: boolean;
enable?: boolean;
Expand Down Expand Up @@ -66,6 +74,22 @@ Must be a boolean. Non-boolean values are coerced today but will be rejected in

Additional glob patterns (relative to the project root) to include in the uploaded archive. The bundler output directory is always included.

### auth.method

> default: `apiKey`

Authentication method for uploading app bundles.

Use `apiKey` to send `DD_API_KEY`/`DD_APP_KEY` credentials. Use `oauth` to complete a local Authorization Code + PKCE flow and upload with a short-lived bearer token instead.

You can also set `DATADOG_AUTH_METHOD=oauth` or `DD_AUTH_METHOD=oauth`.

When `auth.method` is `oauth`, the plugin derives OAuth client settings from the resolved Datadog site. The plugin reads tokens from the OS credential store, refreshes expired access tokens when a refresh token is available, and only starts browser authorization when no usable stored token exists.

For first-time authorization, the plugin starts a temporary local HTTP callback server, opens Datadog authorization in the browser, exchanges the authorization code with PKCE, and saves the returned token response for later uploads.

OAuth token and authorization URLs are site-based. The `datad0g.com` site uses the internal Datadog Apps OAuth client; all other sites use the default Datadog Apps OAuth client.

### apps.identifier

> default: an internal computation between the `name` and `repository` fields in `package.json` or from the `git` plugin.
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@
},
"dependencies": {
"@dd/core": "workspace:*",
"@napi-rs/keyring": "1.3.0",
"chalk": "2.3.1",
"eslint-scope": "7.2.2",
"glob": "11.1.0",
"jszip": "3.10.1",
"oauth4webapi": "3.8.6",
"pretty-bytes": "5.6.0"
},
"devDependencies": {
Expand Down
111 changes: 109 additions & 2 deletions packages/plugins/apps/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import * as archive from '@dd/apps-plugin/archive';
import * as assets from '@dd/apps-plugin/assets';
import * as identifier from '@dd/apps-plugin/identifier';
import * as oauth from '@dd/apps-plugin/oauth';
import * as uploader from '@dd/apps-plugin/upload';
import { getPlugins } from '@dd/apps-plugin';
import { DEFAULT_SITE } from '@dd/core/constants';
Expand All @@ -24,6 +25,8 @@ import path from 'path';
import { parseAst } from 'rollup/parseAst';

import { APPS_API_PATH } from './constants';
import type { AppsOptionsWithDefaults } from './types';
import { handleUpload } from './vite/handle-upload';

/** Extract and assert closeBundle from the first plugin's vite hooks. */
function extractCloseBundle(plugins: PluginOptions[]) {
Expand Down Expand Up @@ -211,8 +214,9 @@ describe('Apps Plugin - getPlugins', () => {
expect(uploader.uploadArchive).toHaveBeenCalledWith(
expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }),
{
apiKey: '123',
appKey: '123',
accessToken: undefined,
apiKey: undefined,
appKey: undefined,
bundlerName: 'vite',
dryRun: true,
identifier: 'repo:app',
Expand All @@ -230,6 +234,109 @@ describe('Apps Plugin - getPlugins', () => {
expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-'));
});

test('Should authorize with OAuth before uploading assets when configured', async () => {
jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({
identifier: 'repo:app',
name: 'test-app',
});
jest.spyOn(assets, 'collectAssets').mockResolvedValue([
{ absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' },
]);
jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined);
jest.spyOn(archive, 'createArchive').mockResolvedValue({
archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip',
assets: [],
size: 10,
});
jest.spyOn(oauth, 'getOAuthToken').mockResolvedValue({
accessToken: 'oauth-token',
site: 'datadoghq.eu',
});
jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ errors: [], warnings: [] });

const closeBundle = extractCloseBundle(
getPlugins(
getGetPluginsArg(
{
auth: {
method: 'oauth',
site: DEFAULT_SITE,
},
apps: {
dryRun: false,
},
},
{
bundler: { ...getMockBundler({ name: 'vite' }), outDir },
buildRoot,
git: getRepositoryDataMock({ remote: 'git@github.com:org/repo.git' }),
},
),
),
);
await closeBundle();

expect(oauth.getOAuthToken).toHaveBeenCalledWith(
DEFAULT_SITE,
expect.objectContaining({
authorizationUrl: `https://api.${DEFAULT_SITE}/oauth2/v1/authorize`,
tokenUrl: `https://api.${DEFAULT_SITE}/oauth2/v1/token`,
}),
expect.anything(),
);
expect(uploader.uploadArchive).toHaveBeenCalledWith(
expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }),
expect.objectContaining({
accessToken: 'oauth-token',
apiKey: undefined,
appKey: undefined,
site: 'datadoghq.eu',
}),
expect.anything(),
);
});

test('Should use API credentials when upload method is not specified', async () => {
jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({
identifier: 'repo:app',
name: 'test-app',
});
jest.spyOn(assets, 'collectAssets').mockResolvedValue([
{ absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' },
]);
jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined);
jest.spyOn(archive, 'createArchive').mockResolvedValue({
archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip',
assets: [],
size: 10,
});
jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ errors: [], warnings: [] });
const getOAuthTokenSpy = jest.spyOn(oauth, 'getOAuthToken');

await handleUpload({
backendFunctions: [],
backendOutputs: new Map(),
context: getArgs().context,
options: {
dryRun: false,
include: [],
oauth: oauth.getOAuthConfig(DEFAULT_SITE),
} as unknown as AppsOptionsWithDefaults,
});

expect(getOAuthTokenSpy).not.toHaveBeenCalled();
expect(uploader.uploadArchive).toHaveBeenCalledWith(
expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }),
expect.objectContaining({
accessToken: undefined,
apiKey: '123',
appKey: '123',
site: DEFAULT_SITE,
}),
expect.anything(),
);
});

test('Should emit root manifest.json with backend function connection allowlists', async () => {
jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({
identifier: 'repo:app',
Expand Down
35 changes: 35 additions & 0 deletions packages/plugins/apps/src/oauth-dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

export type OAuthAuthorizationServer = import('oauth4webapi').AuthorizationServer;
export type OAuthClient = import('oauth4webapi').Client;
export type OAuthModule = typeof import('oauth4webapi');
export type OAuthTokenEndpointResponse = import('oauth4webapi').TokenEndpointResponse;

type KeyringModule = typeof import('@napi-rs/keyring');

const OAUTH_PACKAGE_NAME = 'oauth4webapi';
const KEYRING_PACKAGE_NAME = '@napi-rs/keyring';

const memoizeAsync = <Value>(load: () => Promise<Value>) => {
let promise: Promise<Value> | undefined;

return () => {
if (!promise) {
promise = load().catch((error) => {
promise = undefined;
throw error;
});
}
return promise;
};
};

export const loadOauth = memoizeAsync(() => import(OAUTH_PACKAGE_NAME) as Promise<OAuthModule>);

// `@napi-rs/keyring` has a CommonJS entry. Load it lazily so API-key uploads
// do not initialize the native credential binding.
export const loadKeyring = memoizeAsync(
() => import(KEYRING_PACKAGE_NAME) as Promise<KeyringModule>,
);
Loading
Loading