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
139 changes: 139 additions & 0 deletions packages/api/src/auth/cloud-environment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
PUBLIC,
US_GOV,
US_GOV_DOD,
CHINA,
cloudFromName,
withOverrides,
} from './cloud-environment';

describe('CloudEnvironment', () => {
describe('presets', () => {
it('PUBLIC has correct endpoints', () => {
expect(PUBLIC.loginEndpoint).toBe('https://login.microsoftonline.com');
expect(PUBLIC.loginTenant).toBe('botframework.com');
expect(PUBLIC.botScope).toBe('https://api.botframework.com/.default');
expect(PUBLIC.tokenServiceUrl).toBe('https://token.botframework.com');
expect(PUBLIC.openIdMetadataUrl).toBe('https://login.botframework.com/v1/.well-known/openidconfiguration');
expect(PUBLIC.tokenIssuer).toBe('https://api.botframework.com');
});

it('US_GOV has correct endpoints', () => {
expect(US_GOV.loginEndpoint).toBe('https://login.microsoftonline.us');
expect(US_GOV.loginTenant).toBe('MicrosoftServices.onmicrosoft.us');
expect(US_GOV.botScope).toBe('https://api.botframework.us/.default');
expect(US_GOV.tokenServiceUrl).toBe('https://tokengcch.botframework.azure.us');
expect(US_GOV.openIdMetadataUrl).toBe('https://login.botframework.azure.us/v1/.well-known/openidconfiguration');
expect(US_GOV.tokenIssuer).toBe('https://api.botframework.us');
});

it('US_GOV_DOD has correct endpoints', () => {
expect(US_GOV_DOD.loginEndpoint).toBe('https://login.microsoftonline.us');
expect(US_GOV_DOD.loginTenant).toBe('MicrosoftServices.onmicrosoft.us');
expect(US_GOV_DOD.botScope).toBe('https://api.botframework.us/.default');
expect(US_GOV_DOD.tokenServiceUrl).toBe('https://apiDoD.botframework.azure.us');
expect(US_GOV_DOD.openIdMetadataUrl).toBe('https://login.botframework.azure.us/v1/.well-known/openidconfiguration');
expect(US_GOV_DOD.tokenIssuer).toBe('https://api.botframework.us');
});

it('CHINA has correct endpoints', () => {
expect(CHINA.loginEndpoint).toBe('https://login.partner.microsoftonline.cn');
expect(CHINA.loginTenant).toBe('microsoftservices.partner.onmschina.cn');
expect(CHINA.botScope).toBe('https://api.botframework.azure.cn/.default');
expect(CHINA.tokenServiceUrl).toBe('https://token.botframework.azure.cn');
expect(CHINA.openIdMetadataUrl).toBe('https://login.botframework.azure.cn/v1/.well-known/openidconfiguration');
expect(CHINA.tokenIssuer).toBe('https://api.botframework.azure.cn');
});

it('presets are frozen', () => {
expect(Object.isFrozen(PUBLIC)).toBe(true);
expect(Object.isFrozen(US_GOV)).toBe(true);
expect(Object.isFrozen(US_GOV_DOD)).toBe(true);
expect(Object.isFrozen(CHINA)).toBe(true);
});
});

describe('cloudFromName', () => {
it.each([
['Public', PUBLIC],
['public', PUBLIC],
['PUBLIC', PUBLIC],
['USGov', US_GOV],
['usgov', US_GOV],
['USGovDoD', US_GOV_DOD],
['usgovdod', US_GOV_DOD],
['China', CHINA],
['china', CHINA],
])('resolves "%s" correctly', (name, expected) => {
expect(cloudFromName(name)).toBe(expected);
});

it.each(['invalid', '', 'Azure'])('throws for unknown name "%s"', (name) => {
expect(() => cloudFromName(name)).toThrow(/Unknown cloud environment/);
});
});

describe('withOverrides', () => {
it('returns equivalent object when no overrides provided', () => {
const result = withOverrides(PUBLIC, {});
expect(result).toEqual(PUBLIC);
});

it('returns equivalent object when all overrides are undefined', () => {
const result = withOverrides(PUBLIC, {
loginEndpoint: undefined,
loginTenant: undefined,
});
expect(result).toEqual(PUBLIC);
});

it('replaces only the overridden property', () => {
const result = withOverrides(PUBLIC, { loginTenant: 'my-tenant-id' });

expect(result).not.toBe(PUBLIC);
expect(result.loginTenant).toBe('my-tenant-id');
expect(result.loginEndpoint).toBe(PUBLIC.loginEndpoint);
expect(result.botScope).toBe(PUBLIC.botScope);
expect(result.tokenServiceUrl).toBe(PUBLIC.tokenServiceUrl);
expect(result.openIdMetadataUrl).toBe(PUBLIC.openIdMetadataUrl);
expect(result.tokenIssuer).toBe(PUBLIC.tokenIssuer);
});

it('replaces multiple properties', () => {
const result = withOverrides(CHINA, {
loginEndpoint: 'https://custom.login.cn',
loginTenant: 'custom-tenant',
tokenServiceUrl: 'https://custom.token.cn',
});

expect(result.loginEndpoint).toBe('https://custom.login.cn');
expect(result.loginTenant).toBe('custom-tenant');
expect(result.tokenServiceUrl).toBe('https://custom.token.cn');
expect(result.botScope).toBe(CHINA.botScope);
expect(result.openIdMetadataUrl).toBe(CHINA.openIdMetadataUrl);
});

it('replaces all properties', () => {
const result = withOverrides(PUBLIC, {
loginEndpoint: 'a',
loginTenant: 'b',
botScope: 'c',
tokenServiceUrl: 'd',
openIdMetadataUrl: 'e',
tokenIssuer: 'f',
});

expect(result.loginEndpoint).toBe('a');
expect(result.loginTenant).toBe('b');
expect(result.botScope).toBe('c');
expect(result.tokenServiceUrl).toBe('d');
expect(result.openIdMetadataUrl).toBe('e');
expect(result.tokenIssuer).toBe('f');
});

it('result is frozen', () => {
const result = withOverrides(PUBLIC, { loginTenant: 'test' });
expect(Object.isFrozen(result)).toBe(true);
});
});
});
105 changes: 105 additions & 0 deletions packages/api/src/auth/cloud-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Bundles all cloud-specific service endpoints for a given Azure environment.
* Use predefined instances (PUBLIC, US_GOV, US_GOV_DOD, CHINA)
* or construct a custom one via withOverrides().
*/
export type CloudEnvironment = {
/** The Azure AD login endpoint (e.g. "https://login.microsoftonline.com") */
readonly loginEndpoint: string;
/** The default multi-tenant login tenant (e.g. "botframework.com") */
readonly loginTenant: string;
/** The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default") */
readonly botScope: string;
/** The Bot Framework token service base URL (e.g. "https://token.botframework.com") */
readonly tokenServiceUrl: string;
/** The OpenID metadata URL for token validation */
readonly openIdMetadataUrl: string;
/** The token issuer for Bot Framework tokens (e.g. "https://api.botframework.com") */
readonly tokenIssuer: string;
/** The Microsoft Graph token scope (e.g. "https://graph.microsoft.com/.default") */
readonly graphScope: string;
};

/** Microsoft public (commercial) cloud. */
export const PUBLIC: CloudEnvironment = Object.freeze({
loginEndpoint: 'https://login.microsoftonline.com',
loginTenant: 'botframework.com',
botScope: 'https://api.botframework.com/.default',
tokenServiceUrl: 'https://token.botframework.com',
openIdMetadataUrl: 'https://login.botframework.com/v1/.well-known/openidconfiguration',
tokenIssuer: 'https://api.botframework.com',
graphScope: 'https://graph.microsoft.com/.default',
});

/** US Government Community Cloud High (GCCH). */
export const US_GOV: CloudEnvironment = Object.freeze({
loginEndpoint: 'https://login.microsoftonline.us',
loginTenant: 'MicrosoftServices.onmicrosoft.us',
botScope: 'https://api.botframework.us/.default',
tokenServiceUrl: 'https://tokengcch.botframework.azure.us',
openIdMetadataUrl: 'https://login.botframework.azure.us/v1/.well-known/openidconfiguration',
tokenIssuer: 'https://api.botframework.us',
graphScope: 'https://graph.microsoft.us/.default',
});

/** US Government Department of Defense (DoD). */
export const US_GOV_DOD: CloudEnvironment = Object.freeze({
loginEndpoint: 'https://login.microsoftonline.us',
loginTenant: 'MicrosoftServices.onmicrosoft.us',
botScope: 'https://api.botframework.us/.default',
tokenServiceUrl: 'https://apiDoD.botframework.azure.us',
openIdMetadataUrl: 'https://login.botframework.azure.us/v1/.well-known/openidconfiguration',
tokenIssuer: 'https://api.botframework.us',
graphScope: 'https://dod-graph.microsoft.us/.default',
});

/** China cloud (21Vianet). */
export const CHINA: CloudEnvironment = Object.freeze({
loginEndpoint: 'https://login.partner.microsoftonline.cn',
loginTenant: 'microsoftservices.partner.onmschina.cn',
botScope: 'https://api.botframework.azure.cn/.default',
tokenServiceUrl: 'https://token.botframework.azure.cn',
openIdMetadataUrl: 'https://login.botframework.azure.cn/v1/.well-known/openidconfiguration',
tokenIssuer: 'https://api.botframework.azure.cn',
graphScope: 'https://microsoftgraph.chinacloudapi.cn/.default',
});

/**
* Creates a new CloudEnvironment by applying non-null overrides on top of a base.
* Returns the base instance if no override values differ.
*/
export function withOverrides(
base: CloudEnvironment,
overrides: Partial<CloudEnvironment>
): CloudEnvironment {
return Object.freeze({
loginEndpoint: overrides.loginEndpoint ?? base.loginEndpoint,
loginTenant: overrides.loginTenant ?? base.loginTenant,
botScope: overrides.botScope ?? base.botScope,
tokenServiceUrl: overrides.tokenServiceUrl ?? base.tokenServiceUrl,
openIdMetadataUrl: overrides.openIdMetadataUrl ?? base.openIdMetadataUrl,
tokenIssuer: overrides.tokenIssuer ?? base.tokenIssuer,
graphScope: overrides.graphScope ?? base.graphScope,
});
}

const CLOUD_ENVIRONMENTS: Record<string, CloudEnvironment> = {
public: PUBLIC,
usgov: US_GOV,
usgovdod: US_GOV_DOD,
china: CHINA,
};

/**
* Resolves a cloud environment name (case-insensitive) to its corresponding instance.
* Valid names: "Public", "USGov", "USGovDoD", "China".
*/
export function cloudFromName(name: string): CloudEnvironment {
const env = CLOUD_ENVIRONMENTS[name.toLowerCase()];
if (!env) {
throw new Error(
`Unknown cloud environment: '${name}'. Valid values are: Public, USGov, USGovDoD, China.`
);
}
return env;
}
1 change: 1 addition & 0 deletions packages/api/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './token';
export * from './json-web-token';
export * from './credentials';
export * from './cloud-environment';
16 changes: 10 additions & 6 deletions packages/api/src/clients/api-client-settings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CloudEnvironment } from '../auth/cloud-environment';

export type ApiClientSettings = {
/**
* the URL to use for managing user oauth tokens.
Expand All @@ -13,14 +15,16 @@ export const DEFAULT_API_CLIENT_SETTINGS: ApiClientSettings = {
};

export function mergeApiClientSettings(
apiClientSettings?: Partial<ApiClientSettings>
apiClientSettings?: Partial<ApiClientSettings>,
cloud?: CloudEnvironment
): ApiClientSettings {
const env = typeof process === 'undefined' ? undefined : process.env;

const defaultOauthUrl = cloud?.tokenServiceUrl ?? DEFAULT_API_CLIENT_SETTINGS.oauthUrl;

return {
oauthUrl:
apiClientSettings?.oauthUrl ??
env?.OAUTH_URL ??
DEFAULT_API_CLIENT_SETTINGS.oauthUrl,
oauthUrl:
apiClientSettings?.oauthUrl ??
env?.OAUTH_URL ??
defaultOauthUrl,
};
}
6 changes: 4 additions & 2 deletions packages/api/src/clients/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as http from '@microsoft/teams.common/http';

import { CloudEnvironment } from '../auth/cloud-environment';

import { ApiClientSettings, mergeApiClientSettings } from './api-client-settings';
import { BotClient } from './bot';
import { ConversationClient } from './conversation';
Expand Down Expand Up @@ -36,7 +38,7 @@ export class Client {
protected _http: http.Client;
protected _apiClientSettings: Partial<ApiClientSettings>;

constructor(serviceUrl: string, options?: http.Client | http.ClientOptions, apiClientSettings?: Partial<ApiClientSettings>) {
constructor(serviceUrl: string, options?: http.Client | http.ClientOptions, apiClientSettings?: Partial<ApiClientSettings>, cloud?: CloudEnvironment) {
this.serviceUrl = serviceUrl;

if (!options) {
Expand All @@ -53,7 +55,7 @@ export class Client {
});
}

this._apiClientSettings = mergeApiClientSettings(apiClientSettings);
this._apiClientSettings = mergeApiClientSettings(apiClientSettings, cloud);

this.bots = new BotClient(this.http, this._apiClientSettings);
this.users = new UserClient(this.http, this._apiClientSettings);
Expand Down
26 changes: 23 additions & 3 deletions packages/apps/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
ActivityLike,
ApiClientSettings,
ChannelID,
CloudEnvironment,
ConversationReference,
cloudFromName,
InvokeResponse,
PUBLIC,
StripMentionsTextOptions,
toActivityParams,
TokenCredentials,
Expand Down Expand Up @@ -157,7 +160,15 @@ export type AppOptions<TPlugin extends IPlugin> = {
/**
* API client settings used for overriding.
*/
readonly apiClientSettings?: ApiClientSettings
readonly apiClientSettings?: ApiClientSettings;

/**
* Cloud environment for sovereign cloud support.
* Accepts a CloudEnvironment object or uses CLOUD environment variable.
* Valid env var values: "Public", "USGov", "USGovDoD", "China".
* Defaults to PUBLIC (commercial cloud).
*/
readonly cloud?: CloudEnvironment;
};

export type AppActivityOptions = {
Expand All @@ -175,6 +186,7 @@ export type AppActivityOptions = {
*/
export class App<TPlugin extends IPlugin = IPlugin> {
readonly api: ApiClient;
readonly cloud: CloudEnvironment;
readonly graph: GraphClient;
readonly log: ILogger;
readonly server: HttpServer;
Expand Down Expand Up @@ -255,6 +267,11 @@ export class App<TPlugin extends IPlugin = IPlugin> {
this.log = this.options.logger || new ConsoleLogger('@teams/app');
this.storage = this.options.storage || new LocalStorage();
this._manifest = this.options.manifest || {};

// Resolve cloud environment from options or CLOUD env var
const cloudEnvName = typeof process !== 'undefined' ? process.env.CLOUD : undefined;
this.cloud = this.options.cloud ?? (cloudEnvName ? cloudFromName(cloudEnvName) : PUBLIC);

if (!options.client) {
this.client = new http.Client({
headers: {
Expand Down Expand Up @@ -288,7 +305,8 @@ export class App<TPlugin extends IPlugin = IPlugin> {
this.api = new ApiClient(
serviceUrl,
this.client.clone({ token: () => this.getBotToken() }),
this.options.apiClientSettings
this.options.apiClientSettings,
this.cloud
);

this.graph = new GraphClient(
Expand All @@ -302,6 +320,7 @@ export class App<TPlugin extends IPlugin = IPlugin> {
tenantId: this.options.tenantId,
token: this.options.token,
managedIdentityClientId: this.options.managedIdentityClientId,
cloud: this.cloud,
}, this.log);

// initialize ActivitySender for sending activities
Expand All @@ -314,7 +333,7 @@ export class App<TPlugin extends IPlugin = IPlugin> {
this.entraTokenValidator = middleware.createEntraTokenValidator(
this.credentials.tenantId || 'common',
this.credentials.clientId,
{ applicationIdUri: this.options.applicationIdUri, logger: this.log }
{ applicationIdUri: this.options.applicationIdUri, loginEndpoint: this.cloud.loginEndpoint, logger: this.log }
);
}

Expand Down Expand Up @@ -445,6 +464,7 @@ export class App<TPlugin extends IPlugin = IPlugin> {
// initialize server
await this.server.initialize({
credentials: this.credentials,
cloud: this.cloud,
});

this.isInitialized = true;
Expand Down
Loading
Loading