Skip to content
Closed
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
6 changes: 3 additions & 3 deletions packages/core/src/helpers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const yellow = chalk.bold.yellow;
// - DATADOG_APPS_IDENTIFIER
// - DD_APPS_NAME
// - DATADOG_APPS_NAME
// - DD_APPS_AUTH_METHOD
// - DATADOG_APPS_AUTH_METHOD
// - DD_OAUTH_ACCESS_TOKEN
// - DATADOG_OAUTH_ACCESS_TOKEN
// - DD_SITE
// - DATADOG_SITE
export const OVERRIDE_VARIABLES = [
Expand All @@ -33,7 +33,7 @@ export const OVERRIDE_VARIABLES = [
'APPS_PACKAGE_DIR',
'APPS_IDENTIFIER',
'APPS_NAME',
'APPS_AUTH_METHOD',
'OAUTH_ACCESS_TOKEN',
'SITE',
] as const;
type ENV_KEY = (typeof OVERRIDE_VARIABLES)[number];
Expand Down
18 changes: 14 additions & 4 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned

<!-- #toc -->
- [Configuration](#configuration)
- [Development server authentication](#development-server-authentication)
- [Package output](#package-output)
- [apps.enable](#appsenable)
- [apps.include](#appsinclude)
Expand All @@ -33,19 +34,28 @@ apps?: {
protectionLevel?: 'direct_publish' | 'approval_required';
runAs?: string;
};
authOverrides?: {
method?: 'apiKey' | 'oauth';
};
}
```

## Development server authentication

Backend function execution authenticates in this order:

1. `DD_API_KEY`/`DATADOG_API_KEY` + `DD_APP_KEY`/`DATADOG_APP_KEY` (API-key auth)
2. `DD_OAUTH_ACCESS_TOKEN` (or `DATADOG_OAUTH_ACCESS_TOKEN`)

`datadog-apps dev` resolves and refreshes an OAuth token for your org, then
passes it to the dev server via `DD_OAUTH_ACCESS_TOKEN`. When no credentials are
configured, backend function execution is unavailable and the dev server tells
you to start it with `datadog-apps dev`.

## Package output

A production `vite build` writes `datadog-apps-assets.zip` and `datadog-apps-build.json` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`; the sidecar supplies schema version, bundle filename, identifier, and name for the CLI handoff.

Set `DATADOG_APPS_PACKAGE_DIR` (or `DD_APPS_PACKAGE_DIR`) to write both files to a different directory. `DATADOG_APPS_IDENTIFIER`/`DD_APPS_IDENTIFIER` and `DATADOG_APPS_NAME`/`DD_APPS_NAME` override the resolved identity for a CLI child build.

Use `datadog-apps build` to package locally, `datadog-apps upload` to create a draft, and `datadog-apps deploy` to upload and publish. Production packaging makes no Datadog API requests. Development-server backend functions retain their existing authentication behavior.
Use `datadog-apps build` to package locally, `datadog-apps upload` to create a draft, and `datadog-apps deploy` to upload and publish. Production packaging makes no Datadog API requests. Development-server authentication is described above.

### apps.enable

Expand Down
80 changes: 40 additions & 40 deletions packages/plugins/apps/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,77 +3,77 @@
// Copyright 2019-Present Datadog, Inc.

import { getAuthenticatedRequest, MissingAuthenticationError } from '@dd/apps-plugin/auth';
import { doOAuthRequest } from '@dd/core/helpers/oauth-request';
import { doRequest } from '@dd/core/helpers/request';
import { getMockLogger } from '@dd/tests/_jest/helpers/mocks';

jest.mock('@dd/core/helpers/oauth-request', () => ({
doOAuthRequest: jest.fn(),
}));
import { cleanEnv } from '@dd/tests/_jest/helpers/env';

jest.mock('@dd/core/helpers/request', () => ({
doRequest: jest.fn(),
}));

const doOAuthRequestMock = jest.mocked(doOAuthRequest);
const doRequestMock = jest.mocked(doRequest);

describe('Apps Plugin - auth', () => {
let restoreEnv: () => void;

beforeEach(() => {
restoreEnv = cleanEnv();
});

afterEach(() => {
restoreEnv();
jest.clearAllMocks();
});

test('Should build an OAuth request function', async () => {
doOAuthRequestMock.mockResolvedValue('ok');
const log = getMockLogger();
const doAuthenticatedRequest = getAuthenticatedRequest(
'oauth',
{ site: 'datadoghq.com' },
log,
);
test('Should prefer API-key auth when both keys are set', async () => {
process.env.DD_API_KEY = 'api-key';
process.env.DD_APP_KEY = 'app-key';
process.env.DD_OAUTH_ACCESS_TOKEN = 'oauth-token';
doRequestMock.mockResolvedValue('ok');

await expect(
doAuthenticatedRequest({ url: 'https://api.datadoghq.com/test' }),
getAuthenticatedRequest()({ url: 'https://api.datadoghq.com/test' }),
).resolves.toBe('ok');
expect(doOAuthRequestMock).toHaveBeenCalledWith({
expect(doRequestMock).toHaveBeenCalledWith({
url: 'https://api.datadoghq.com/test',
auth: { site: 'datadoghq.com' },
log,
auth: {
apiKey: 'api-key',
appKey: 'app-key',
},
});
});

test('Should build an API-key request function when both keys are available', async () => {
test('Should fall back to the OAuth access token when API keys are absent', async () => {
process.env.DD_OAUTH_ACCESS_TOKEN = 'oauth-token';
doRequestMock.mockResolvedValue('ok');
const log = getMockLogger();
const doAuthenticatedRequest = getAuthenticatedRequest(
'apiKey',
{
apiKey: 'api-key',
appKey: 'app-key',
site: 'datadoghq.com',

await expect(
getAuthenticatedRequest()({ url: 'https://api.datadoghq.com/test' }),
).resolves.toBe('ok');
expect(doRequestMock).toHaveBeenCalledWith({
url: 'https://api.datadoghq.com/test',
auth: {
accessToken: 'oauth-token',
},
log,
);
});
});

test('Should not use API-key auth when only one key is set', async () => {
process.env.DD_API_KEY = 'api-key';
process.env.DD_OAUTH_ACCESS_TOKEN = 'oauth-token';
doRequestMock.mockResolvedValue('ok');

await expect(
doAuthenticatedRequest({ url: 'https://api.datadoghq.com/test' }),
getAuthenticatedRequest()({ 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',
accessToken: 'oauth-token',
},
});
});

test('Should throw when API-key credentials are incomplete', () => {
expect(() =>
getAuthenticatedRequest(
'apiKey',
{ apiKey: 'api-key', site: 'datadoghq.com' },
getMockLogger(),
),
).toThrow(MissingAuthenticationError);
test('Should throw when no credentials are configured', () => {
expect(() => getAuthenticatedRequest()).toThrow(MissingAuthenticationError);
});
});
39 changes: 22 additions & 17 deletions packages/plugins/apps/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import { doOAuthRequest } from '@dd/core/helpers/oauth-request';
import { getDDEnvValue } from '@dd/core/helpers/env';
import { doRequest } from '@dd/core/helpers/request';
import type { AuthOptionsWithDefaults, Logger, RequestOpts } from '@dd/core/types';

import type { AuthMethod } from './types';
import type { RequestOpts } from '@dd/core/types';

export const AUTH_GUIDANCE =
'Set apps.authOverrides.method: "oauth" or DD_APPS_AUTH_METHOD=oauth to use OAuth, ' +
'or set DD_API_KEY and DD_APP_KEY to use API/App key auth.';
'Set DD_API_KEY and DD_APP_KEY for API-key auth, or set DD_OAUTH_ACCESS_TOKEN ' +
'(or DATADOG_OAUTH_ACCESS_TOKEN) — e.g. by starting the dev server with `datadog-apps dev`.';

export type DoAuthenticatedRequest = <T>(opts: Omit<RequestOpts, 'auth'>) => Promise<T>;

Expand All @@ -23,23 +21,30 @@ export class MissingAuthenticationError extends Error {
}
}

// Build the authenticated request function from the resolved method + base credentials.
export const getAuthenticatedRequest = (
method: AuthMethod,
auth: AuthOptionsWithDefaults,
log: Logger,
): DoAuthenticatedRequest => {
if (method === 'oauth') {
return (opts) => doOAuthRequest({ ...opts, auth, log });
// Build the dev-server request authenticator. API-key auth (DD_API_KEY +
// DD_APP_KEY) takes precedence; otherwise the OAuth access token that
// @datadog/apps-cli passes via DD_OAUTH_ACCESS_TOKEN is used.
export const getAuthenticatedRequest = (): DoAuthenticatedRequest => {
const apiKey = getDDEnvValue('API_KEY');
const appKey = getDDEnvValue('APP_KEY');
if (apiKey && appKey) {
return (opts) =>
doRequest({
...opts,
auth: {
apiKey,
appKey,
},
});
}

if (auth.apiKey && auth.appKey) {
const accessToken = getDDEnvValue('OAUTH_ACCESS_TOKEN');
if (accessToken) {
return (opts) =>
doRequest({
...opts,
auth: {
apiKey: auth.apiKey,
appKey: auth.appKey,
accessToken,
},
});
}
Expand Down
1 change: 0 additions & 1 deletion packages/plugins/apps/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ describe('Apps Plugin - package output', () => {
}),
options: {
include: [],
authOverrides: { method: 'oauth' as const },
...overrides.options,
},
};
Expand Down
21 changes: 2 additions & 19 deletions packages/plugins/apps/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import type { Assign, WithRequired } from '@dd/core/types';

export type AuthMethod = 'apiKey' | 'oauth';
import type { WithRequired } from '@dd/core/types';

export type AppsProtectionLevel = 'direct_publish' | 'approval_required';

Expand Down Expand Up @@ -32,11 +30,6 @@ export type AppsOptions = {
*/
runAs?: 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 = {
Expand All @@ -61,14 +54,4 @@ export type AppsManifest = {
};

// We don't enforce identifier, as it needs to be dynamically computed if absent.
export type AppsOptionsWithDefaults = Omit<
Assign<
WithRequired<AppsOptions, 'include'>,
{
authOverrides: {
method: AuthMethod;
};
}
>,
'enable'
>;
export type AppsOptionsWithDefaults = WithRequired<AppsOptions, 'include'>;
57 changes: 1 addition & 56 deletions packages/plugins/apps/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,69 +17,14 @@ describe('Apps Plugin - validateOptions', () => {
restoreEnv();
});

test('uses package-only defaults and OAuth when credentials are absent', () => {
test('uses package-only defaults when credentials are absent', () => {
expect(validateOptions({ apps: {} })).toEqual({
include: [],
identifier: undefined,
name: undefined,
authOverrides: { method: 'oauth' },
});
});

test('uses API-key auth when both keys are configured via auth option', () => {
const result = validateOptions({
auth: { apiKey: 'api-key', appKey: 'app-key' },
});
expect(result.authOverrides.method).toBe('apiKey');
});

test('uses API-key auth only when both keys are available', () => {
process.env.DATADOG_API_KEY = 'api-key';
process.env.DATADOG_APP_KEY = 'app-key';

expect(validateOptions({ apps: {} }).authOverrides.method).toBe('apiKey');
});

test('defaults to OAuth when API-key auth is incomplete', () => {
const result = validateOptions({
auth: { apiKey: 'api-key' },
});
expect(result.authOverrides.method).toBe('oauth');
});

test('respects explicit OAuth method over available API/App keys', () => {
const result = validateOptions({
auth: { apiKey: 'api-key', appKey: 'app-key' },
apps: { authOverrides: { method: 'oauth' } },
});
expect(result.authOverrides.method).toBe('oauth');
});

test('respects explicit apiKey method when no keys are configured', () => {
const result = validateOptions({
apps: { authOverrides: { method: 'apiKey' } },
});
expect(result.authOverrides.method).toBe('apiKey');
});

test('allows env var to override auth method to OAuth', () => {
process.env.DATADOG_APPS_AUTH_METHOD = 'oauth';

expect(validateOptions({ apps: {} }).authOverrides.method).toBe('oauth');
});

test('allows env var to override auth method to apiKey', () => {
process.env.DATADOG_APPS_AUTH_METHOD = 'apiKey';

expect(validateOptions({ apps: {} }).authOverrides.method).toBe('apiKey');
});

test('throws on invalid auth method', () => {
expect(() =>
validateOptions({ apps: { authOverrides: { method: 'invalid' as never } } }),
).toThrow('apps.authOverrides.method must be one of: apiKey, oauth');
});

test('uses environment identity overrides before plugin configuration', () => {
process.env.DATADOG_APPS_IDENTIFIER = 'command-id';
process.env.DATADOG_APPS_NAME = 'Command Name';
Expand Down
Loading
Loading