Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# Core
packages/core/src/helpers/oauth-request.ts @DataDog/app-builder-high-code @DataDog/build-plugins
packages/core/src/helpers/oauth-request.test.ts @DataDog/app-builder-high-code @DataDog/build-plugins
packages/core/src/helpers/site.ts @DataDog/app-builder-high-code @DataDog/build-plugins
packages/core/src/helpers/site.test.ts @DataDog/app-builder-high-code @DataDog/build-plugins

# Build Report
packages/plugins/build-report @DataDog/build-plugins
Expand Down
2 changes: 1 addition & 1 deletion MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ This replaces the individual endpoint configurations at the product level.
}
```

Supported `site` include: `'datadoghq.com'` (default), `'datadoghq.eu'`, `'us3.datadoghq.com'`, `'us5.datadoghq.com'`, `'ap1.datadoghq.com'`, etc.
Supported `site` include: `'datadoghq.com'` (default), `'datadoghq.eu'`, `'us3.datadoghq.com'`, `'us5.datadoghq.com'`, `'ap1.datadoghq.com'`, etc. You can also prefix any of these with a custom subdomain (e.g. `'myorg.us5.datadoghq.com'`) if your organization uses a custom Datadog URL.

> [!NOTE]
> - You can still use `DATADOG_SOURCEMAP_INTAKE_URL` to override the sourcemaps' intake url.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ The Datadog site to use APIs from, and which Datadog site telemetry metrics and
- `'ap1.datadoghq.com'`
- `'ap2.datadoghq.com'`

You can also prefix any of the sites above with a custom subdomain (e.g. `'myorg.us5.datadoghq.com'`), if your organization uses a custom Datadog URL. All API calls (metrics, error tracking sourcemaps, Apps uploads) still go to the base site; the subdomain is only used to send you to your org's custom URL during the [Apps OAuth flow](/packages/plugins/apps#appsauthoverridesmethod).

An unsupported value (passed via configuration or the `DATADOG_SITE` / `DD_SITE` environment variable) will throw at plugin initialization.

> [!NOTE]
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/helpers/oauth-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,15 @@ describe('Core - OAuth', () => {
expect(config.authorizationUrl).toBe('https://app.datad0g.com/oauth2/v1/authorize');
expect(config.tokenUrl).toBe('https://api.datad0g.com/oauth2/v1/token');
});

test('Should direct the authorization URL to a custom subdomain when given one', () => {
const config = getDatadogOAuthConfig('us5.datadoghq.com', 'customsubdomain');
expect(config.authorizationUrl).toBe(
'https://customsubdomain.us5.datadoghq.com/oauth2/v1/authorize',
);
// The token exchange always stays on the base site's API host.
expect(config.tokenUrl).toBe('https://api.us5.datadoghq.com/oauth2/v1/token');
});
});

describe('resolveOAuthToken', () => {
Expand Down
31 changes: 21 additions & 10 deletions packages/core/src/helpers/oauth-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,12 @@ export const getOAuthClientId = (site: string) => {
}
};

export const getDatadogOAuthConfig = (site: string): OAuthConfig => {
export const getDatadogOAuthConfig = (site: string, subdomain?: string): OAuthConfig => {
const clientId = getOAuthClientId(site);
const authorizationUrl = `https://app.${site}/oauth2/v1/authorize`;
// Custom subdomains are a web-UI alias for the org's "app." host; API/token
// calls always stay on the base site.
const authorizationHost = subdomain ? `${subdomain}.${site}` : `app.${site}`;
const authorizationUrl = `https://${authorizationHost}/oauth2/v1/authorize`;
const tokenUrl = `https://api.${site}/oauth2/v1/token`;

return {
Expand Down Expand Up @@ -632,8 +635,8 @@ export const getOAuthToken = async (
return token;
};

// Memoize per site+client for the lifetime of the process so concurrent requests
// (and the sequential upload + release calls) share a single browser authorization.
// Memoize per site+subdomain+client for the lifetime of the process so concurrent
// requests (and the sequential upload + release calls) share a single browser authorization.
const tokenCache = new Map<string, Promise<ResolvedOAuthToken>>();

const resolveOAuthTokenFromStorage = async (
Expand All @@ -650,9 +653,13 @@ const resolveOAuthTokenFromStorage = async (
return { persistAfterSuccessfulRequest: true, token };
};

export const resolveOAuthToken = async (site: string, log: Logger): Promise<ResolvedOAuthToken> => {
const options = getDatadogOAuthConfig(site);
const key = `${site}:${options.clientId}`;
export const resolveOAuthToken = async (
site: string,
log: Logger,
subdomain?: string,
): Promise<ResolvedOAuthToken> => {
const options = getDatadogOAuthConfig(site, subdomain);
const key = `${site}:${subdomain ?? ''}:${options.clientId}`;
let pending = tokenCache.get(key);
if (!pending) {
pending = resolveOAuthTokenFromStorage(site, options, log).catch((error) => {
Expand All @@ -670,8 +677,12 @@ export type OAuthRequestOpts = Omit<RequestOpts, 'auth'> & {
};

export const doOAuthRequest = async <T>({ auth, log, ...opts }: OAuthRequestOpts): Promise<T> => {
const { site } = auth;
const { persistAfterSuccessfulRequest, token } = await resolveOAuthToken(site, log);
const { site, siteSubdomain } = auth;
const { persistAfterSuccessfulRequest, token } = await resolveOAuthToken(
site,
log,
siteSubdomain,
);

if (!token.accessToken) {
throw new Error('OAuth authentication did not return an access token.');
Expand All @@ -685,7 +696,7 @@ export const doOAuthRequest = async <T>({ auth, log, ...opts }: OAuthRequestOpts
});

if (persistAfterSuccessfulRequest) {
await saveOAuthToken(token, getDatadogOAuthConfig(site), log, site);
await saveOAuthToken(token, getDatadogOAuthConfig(site, siteSubdomain), log, site);
}

return result;
Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/helpers/site.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 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 { parseSite } from '@dd/core/helpers/site';

describe('Core Helpers', () => {
describe('parseSite', () => {
const cases = [
{
description: 'accept a bare known site unchanged',
input: 'us5.datadoghq.com',
expected: { site: 'us5.datadoghq.com' },
},
{
description: 'accept a custom subdomain on top of a known site',
input: 'customsubdomain.us5.datadoghq.com',
expected: { site: 'us5.datadoghq.com', subdomain: 'customsubdomain' },
},
{
description: 'accept a custom subdomain on top of a bare site',
input: 'foobar.datadoghq.com',
expected: { site: 'datadoghq.com', subdomain: 'foobar' },
},
{
description: 'prefer the longest (most specific) matching base site',
input: 'myorg.us5.datadoghq.com',
expected: { site: 'us5.datadoghq.com', subdomain: 'myorg' },
},
{
description: 'match case-insensitively and normalize to lowercase',
input: 'CustomSubdomain.US5.DatadogHQ.com',
expected: { site: 'us5.datadoghq.com', subdomain: 'customsubdomain' },
},
{
description: 'reject a site that is not a known site or subdomain of one',
input: 'not-a-real-site.example.com',
expected: undefined,
},
{
description: 'reject a subdomain with multiple labels',
input: 'foo.bar.datadoghq.com',
expected: undefined,
},
{
description: 'reject a subdomain with invalid characters',
input: 'foo_bar.datadoghq.com',
expected: undefined,
},
{
description: 'reject a non-string value instead of throwing',
input: 123 as unknown as string,
expected: undefined,
},
{
description: 'reject a null value instead of throwing',
input: null as unknown as string,
expected: undefined,
},
];

test.each(cases)('should $description', ({ input, expected }) => {
expect(parseSite(input)).toEqual(expected);
});
});
});
40 changes: 40 additions & 0 deletions packages/core/src/helpers/site.ts
Comment thread
oliverli marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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 { SITES } from '@dd/core/constants';

// A site value can be a bare known site (e.g. "datadoghq.com") or a single-label
// custom subdomain on top of one (e.g. "myorg.us5.datadoghq.com"), used by orgs
// with a custom Datadog URL. Picks the longest (most specific) matching base site
// so "myorg.us5.datadoghq.com" resolves to "us5.datadoghq.com", not "datadoghq.com".
// Matching is case-insensitive; the returned site and subdomain are normalized to lowercase.
export const parseSite = (
value: string,
): { site: (typeof SITES)[number]; subdomain?: string } | undefined => {
// Config isn't enforced by the type system at runtime (e.g. plain JS configs),
// so a non-string can reach here despite the `string` signature.
if (typeof value !== 'string') {
return undefined;
}

const lowerValue = value.toLowerCase();

const exactMatch = SITES.find((s) => s === lowerValue);
if (exactMatch) {
return { site: exactMatch };
}

const suffixMatches = SITES.filter((s) => lowerValue.endsWith(`.${s}`));
if (!suffixMatches.length) {
return undefined;
}

const site = suffixMatches.reduce((longest, s) => (s.length > longest.length ? s : longest));
const subdomain = lowerValue.slice(0, lowerValue.length - site.length - 1);
if (!subdomain || !/^[a-z0-9-]+$/.test(subdomain)) {
return undefined;
}

return { site, subdomain };
};
8 changes: 7 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,13 @@ export type AuthOptions = {
site?: string;
};

export type AuthOptionsWithDefaults = Omit<AuthOptions, 'site'> & { site: Site };
export type AuthOptionsWithDefaults = Omit<AuthOptions, 'site'> & {
site: Site;
// A single-label custom subdomain on top of `site`, e.g. "myorg" for
// "myorg.us5.datadoghq.com". Only used to direct the user to their org's
// custom URL in the OAuth authorization step; API calls stay on `site`.
siteSubdomain?: string;
};

export interface BaseOptions {
auth?: AuthOptions;
Expand Down
32 changes: 32 additions & 0 deletions packages/factory/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ describe('factory validateOptions', () => {
});
});

// Exercises validateOptions' own wiring (defaulting, error aggregation) around
// parseSite. Site-format parsing itself (subdomains, case-insensitivity, rejection
// rules) is unit-tested directly in `@dd/core/helpers/site`'s own test file.
describe('auth.site', () => {
Comment thread
oliverli marked this conversation as resolved.
it('should default to the default site when unset', () => {
const result = validateOptions();
expect(result.auth.site).toBe('datadoghq.com');
expect(result.auth.siteSubdomain).toBeUndefined();
});

it('should accept a custom subdomain on top of a known site', () => {
const result = validateOptions({
auth: { site: 'customsubdomain.us5.datadoghq.com' },
});
expect(result.auth.site).toBe('us5.datadoghq.com');
expect(result.auth.siteSubdomain).toBe('customsubdomain');
});

it('should reject a site that is not a known site or subdomain of one with an aggregated validation error', () => {
expect(() =>
validateOptions({ auth: { site: 'not-a-real-site.example.com' } }),
).toThrow(/auth\.site.*is not a supported Datadog site/);
});

it('should reject a non-string site value with the standard validation error, not throw internally', () => {
expect(() =>
// Plain JS configs aren't enforced by the type system at runtime.
validateOptions({ auth: { site: 123 as unknown as string } }),
).toThrow(/auth\.site.*is not a supported Datadog site/);
});
});

describe('metadata validation', () => {
const cases = [
{
Expand Down
20 changes: 11 additions & 9 deletions packages/factory/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,29 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import { DEFAULT_SITE, SITES } from '@dd/core/constants';
import { DEFAULT_SITE } from '@dd/core/constants';
import { getDDEnvValue } from '@dd/core/helpers/env';
import { parseSite } from '@dd/core/helpers/site';
import type {
AuthOptionsWithDefaults,
BuildMetadata,
Options,
OptionsWithDefaults,
Site,
} from '@dd/core/types';

const SITES_DOC_URL = 'https://docs.datadoghq.com/getting_started/site/';

const isSite = (value: string): value is Site => SITES.some((s) => s === value);

const resolveSite = (
value: string | undefined,
source: string,
errors: string[],
): Site | undefined => {
): ReturnType<typeof parseSite> => {
if (value === undefined) {
return undefined;
}
if (isSite(value)) {
return value;
const parsed = parseSite(value);
if (parsed) {
return parsed;
}
errors.push(
`${source} "${value}" is not a supported Datadog site. See the site parameters in ${SITES_DOC_URL}.`,
Expand Down Expand Up @@ -54,10 +53,13 @@ export const validateOptions = (options: Options = {}): OptionsWithDefaults => {
// auth.site when no env var is set, so a stale auth.site can't block a
// build that has already opted into an env override.
const envRaw = getDDEnvValue('SITE');
const envSite = resolveSite(envRaw, 'DATADOG_SITE/DD_SITE', errors);
const resolvedSite =
resolveSite(envRaw, 'DATADOG_SITE/DD_SITE', errors) ??
resolveSite(options.auth?.site, 'auth.site', errors);

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

if (errors.length) {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ When the method is `oauth`, the plugin derives OAuth client settings from the re

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

OAuth token and authorization URLs are derived from `auth.site`, so it must match your Datadog data center (e.g. `datadoghq.com`, `us5.datadoghq.com`, `datadoghq.eu`).
OAuth token and authorization URLs are derived from `auth.site`, so it must match your Datadog data center (e.g. `datadoghq.com`, `us5.datadoghq.com`, `datadoghq.eu`). If `auth.site` includes a custom subdomain (e.g. `myorg.us5.datadoghq.com`), the browser is sent to that subdomain for authorization, while the token exchange and upload requests still use the base site (`us5.datadoghq.com`).

### apps.identifier

Expand Down
Loading
Loading