/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { debug } from '@microsoft/agents-telemetry' import { ConnectionMapItem } from './msalConnectionManager' import objectPath from 'object-path' const logger = debug('agents:authConfiguration') const DEFAULT_CONNECTION = 'serviceConnection' /** * Represents the authentication configuration. */ export interface AuthConfiguration { /** * The tenant ID for the authentication configuration. */ tenantId?: string /** * The client ID for the authentication configuration. Required in production. */ clientId?: string /** * The client secret for the authentication configuration. */ clientSecret?: string /** * The path to the certificate PEM file. */ certPemFile?: string /** * The path to the certificate key file. */ certKeyFile?: string /** * Indicates whether to send the X5C param or not (for SNI authentication). */ sendX5C?: boolean /** * A list of valid issuers for the authentication configuration. */ issuers?: string[] /** * The connection name for the authentication configuration. */ connectionName?: string /** * The FIC (First-Party Integration Channel) client ID. */ FICClientId?: string, /** * Entra Authentication Endpoint to use. * * @remarks * If not populated the Entra Public Cloud endpoint is assumed. * This example of Public Cloud Endpoint is https://login.microsoftonline.com * see also https://learn.microsoft.com/entra/identity-platform/authentication-national-cloud */ authority?: string scope?: string /** * A map of connection names to their respective authentication configurations. */ connections?: Map /** * A list of connection map items to map service URLs to connection names. */ connectionsMap?: ConnectionMapItem[], /** * An optional alternative blueprint Connection name used when constructing a connector client. */ altBlueprintConnectionName?: string /** * The path to K8s provided token. */ WIDAssertionFile?: string } /** * Loads the authentication configuration from environment variables. * * @returns The authentication configuration. * @throws Will throw an error if clientId is not provided in production. * * @remarks * - `clientId` is required * * @example * ``` * tenantId=your-tenant-id * clientId=your-client-id * clientSecret=your-client-secret * * certPemFile=your-cert-pem-file * certKeyFile=your-cert-key-file * sendX5C=false * * FICClientId=your-FIC-client-id * * connectionName=your-connection-name * authority=your-authority-endpoint * ``` * */ export const loadAuthConfigFromEnv = (cnxName?: string): AuthConfiguration => { const envConnections = loadConnectionsMapFromEnv() let authConfig: AuthConfiguration if (envConnections.connectionsMap.length === 0) { // No connections provided, we need to populate the connections map with the old config settings authConfig = buildLegacyAuthConfig(cnxName) envConnections.connections.set(DEFAULT_CONNECTION, authConfig) envConnections.connectionsMap.push({ serviceUrl: '*', connection: DEFAULT_CONNECTION, }) } else { // There are connections provided, use the default or specified connection if (cnxName) { const entry = envConnections.connections.get(cnxName) if (entry) { authConfig = entry } else { throw new Error(`Connection "${cnxName}" not found in environment.`) } } else { const defaultItem = envConnections.connectionsMap.find((item) => item.serviceUrl === '*') const defaultConn = defaultItem ? envConnections.connections.get(defaultItem.connection) : undefined if (!defaultConn) { throw new Error('No default connection found in environment connections.') } authConfig = defaultConn } authConfig.authority ??= 'https://login.microsoftonline.com' authConfig.issuers ??= getDefaultIssuers(authConfig.tenantId ?? '', authConfig.authority) } return { ...authConfig, ...envConnections, } } /** * Loads the agent authentication configuration from previous version environment variables. * * @returns The agent authentication configuration. * @throws Will throw an error if MicrosoftAppId is not provided in production. * * @example * ``` * MicrosoftAppId=your-client-id * MicrosoftAppPassword=your-client-secret * MicrosoftAppTenantId=your-tenant-id * ``` * */ export const loadPrevAuthConfigFromEnv: () => AuthConfiguration = () => { const envConnections = loadConnectionsMapFromEnv() let authConfig: AuthConfiguration = {} if (envConnections.connectionsMap.length === 0) { // No connections provided, we need to populate the connection map with the old config settings if (process.env.MicrosoftAppId === undefined && process.env.NODE_ENV === 'production') { throw new Error('ClientId required in production') } const authority = process.env.authorityEndpoint ?? 'https://login.microsoftonline.com' authConfig = { tenantId: process.env.MicrosoftAppTenantId, clientId: process.env.MicrosoftAppId, clientSecret: process.env.MicrosoftAppPassword, certPemFile: process.env.certPemFile, certKeyFile: process.env.certKeyFile, sendX5C: process.env.sendX5C === 'true', connectionName: process.env.connectionName, FICClientId: process.env.MicrosoftAppClientId, authority, scope: process.env.scope, issuers: getDefaultIssuers(process.env.MicrosoftAppTenantId ?? '', authority), altBlueprintConnectionName: process.env.altBlueprintConnectionName, WIDAssertionFile: process.env.WIDAssertionFile, } envConnections.connections.set(DEFAULT_CONNECTION, authConfig) envConnections.connectionsMap.push({ serviceUrl: '*', connection: DEFAULT_CONNECTION, }) } else { // There are connections provided, use the default one. const defaultItem = envConnections.connectionsMap.find((item) => item.serviceUrl === '*') const defaultConn = defaultItem ? envConnections.connections.get(defaultItem.connection) : undefined if (!defaultConn) { throw new Error('No default connection found in environment connections.') } authConfig = defaultConn } authConfig.authority ??= 'https://login.microsoftonline.com' authConfig.issuers ??= getDefaultIssuers(authConfig.tenantId ?? '', authConfig.authority) return { ...authConfig, ...envConnections } } function loadConnectionsMapFromEnv () { const envVars = process.env const connectionsObj: Record = {} const connectionsMap: ConnectionMapItem[] = [] const CONNECTIONS_PREFIX = 'connections__' const CONNECTIONS_MAP_PREFIX = 'connectionsMap__' for (const [key, rawValue] of Object.entries(envVars)) { if (key.startsWith(CONNECTIONS_PREFIX)) { // Convert to dot notation let path = key.substring(CONNECTIONS_PREFIX.length).replace(/__/g, '.') // Remove ".settings." from the path path = path.replace('.settings.', '.') // Convert "true"/"false" strings into boolean values const value = rawValue === 'true' ? true : rawValue === 'false' ? false : rawValue objectPath.set(connectionsObj, path, value) } else if (key.startsWith(CONNECTIONS_MAP_PREFIX)) { const path = key.substring(CONNECTIONS_MAP_PREFIX.length).replace(/__/g, '.') objectPath.set(connectionsMap, path, rawValue) } } // Convert connectionsObj to Map const connections: Map = new Map(Object.entries(connectionsObj)) if (connections.size === 0) { logger.warn('No connections found in configuration.') } if (connectionsMap.length === 0) { logger.warn('No connections map found in configuration.') if (connections.size > 0) { const firstEntry = connections.entries().next().value if (firstEntry) { const [firstKey] = firstEntry // Provide a default connection map if none is specified connectionsMap.push({ serviceUrl: '*', connection: firstKey, }) } } } return { connections, connectionsMap, } } /** * Loads the authentication configuration from the provided config or from the environment variables * providing default values for authority and issuers. * * @returns The authentication configuration. * @throws Will throw an error if clientId is not provided in production. * * @example * ``` * tenantId=your-tenant-id * clientId=your-client-id * clientSecret=your-client-secret * * certPemFile=your-cert-pem-file * certKeyFile=your-cert-key-file * sendX5C=false * * FICClientId=your-FIC-client-id * * connectionName=your-connection-name * authority=your-authority-endpoint * ``` * */ export function getAuthConfigWithDefaults (config?: AuthConfiguration): AuthConfiguration { if (!config) return loadAuthConfigFromEnv() const providedConnections = config.connections && config.connectionsMap ? { connections: config.connections, connectionsMap: config.connectionsMap } : undefined const connections = providedConnections ?? loadConnectionsMapFromEnv() let mergedConfig: AuthConfiguration if (connections && connections.connectionsMap?.length === 0) { // No connections provided, we need to populate the connections map with the old config settings mergedConfig = buildLegacyAuthConfig(undefined, config) connections.connections?.set(DEFAULT_CONNECTION, mergedConfig) connections.connectionsMap.push({ serviceUrl: '*', connection: DEFAULT_CONNECTION }) } else { // There are connections provided, use the default connection const defaultItem = connections.connectionsMap?.find((item) => item.serviceUrl === '*') const defaultConn = defaultItem ? connections.connections?.get(defaultItem.connection) : undefined if (!defaultConn) { throw new Error('No default connection found in environment connections.') } mergedConfig = buildLegacyAuthConfig(undefined, defaultConn) } return { ...mergedConfig, ...connections, } } function buildLegacyAuthConfig (envPrefix: string = '', customConfig?: AuthConfiguration): AuthConfiguration { const prefix = envPrefix ? `${envPrefix}_` : '' const authority = customConfig?.authority ?? process.env[`${prefix}authorityEndpoint`] ?? 'https://login.microsoftonline.com' const clientId = customConfig?.clientId ?? process.env[`${prefix}clientId`] if (!clientId && !envPrefix && process.env.NODE_ENV === 'production') { throw new Error('ClientId required in production') } if (!clientId && envPrefix) { throw new Error(`ClientId not found for connection: ${envPrefix}`) } const tenantId = customConfig?.tenantId ?? process.env[`${prefix}tenantId`] return { tenantId, clientId: clientId!, clientSecret: customConfig?.clientSecret ?? process.env[`${prefix}clientSecret`], certPemFile: customConfig?.certPemFile ?? process.env[`${prefix}certPemFile`], certKeyFile: customConfig?.certKeyFile ?? process.env[`${prefix}certKeyFile`], sendX5C: customConfig?.sendX5C ?? (process.env[`${prefix}sendX5C`] === 'true'), connectionName: customConfig?.connectionName ?? process.env[`${prefix}connectionName`], FICClientId: customConfig?.FICClientId ?? process.env[`${prefix}FICClientId`], authority, scope: customConfig?.scope ?? process.env[`${prefix}scope`], issuers: customConfig?.issuers ?? getDefaultIssuers(tenantId as string, authority), altBlueprintConnectionName: customConfig?.altBlueprintConnectionName ?? process.env[`${prefix}altBlueprintConnectionName`], WIDAssertionFile: customConfig?.WIDAssertionFile ?? process.env[`${prefix}WIDAssertionFile`] } } /** * Resolves the full authority URL including the tenant ID. * Supports both patterns: * - Tenant embedded in authority: https://login.microsoftonline.com/my-tenant * - Authority + separate tenantId: https://login.microsoftonline.com + tenantId * Also handles trailing slashes on authority. */ export function resolveAuthority (authority?: string, tenantId?: string): string { const base = (authority ?? 'https://login.microsoftonline.com').replace(/\/+$/, '') const url = new URL(base) const hasPathSegment = url.pathname !== '/' if (hasPathSegment) { return base } return `${base}/${tenantId ?? 'botframework.com'}` } function getDefaultIssuers (tenantId: string, authority: string) : string[] { // Convert empty string to undefined so resolveAuthority applies its 'botframework.com' default const t = tenantId || undefined if (!t) { logger.warn('tenantId is not configured, defaulting to botframework.com') } return [ 'https://api.botframework.com', `${resolveAuthority('https://sts.windows.net', t)}/`, `${resolveAuthority(authority, t)}/v2.0` ] } /** * A type representing a parser settings object. */ type ParserSettings = { [key in K]: (value: string) => { key?: string, value?: any } | undefined } /** * Creates an environment variable parser that maps the variable keys to parsing functions. * @param settings An object where each key is an environment variable name and the value is a function * that takes the variable value as input and returns an object with optional `key` and `value` properties. * @remarks * The `key` property in the returned object can be used to rename the environment variable key, * while the `value` property contains the parsed value. * @returns An object with a `parse` method that takes an environment variable key and value, * and returns the parsed result. */ export function envParser (settings: ParserSettings & ThisType>) { const keys = Object.keys(settings) as K[] return { /** * Parses the given environment variable key and value using the provided settings. * @param key The environment variable key. * @param value The environment variable value. * @returns The parsed result with optional renamed key and parsed value. */ parse (key: K, value: string) { const match = keys.find(k => k.toUpperCase() === key.toUpperCase()) if (!match) { return {} } const result = settings[match](value) return { key: result?.key ?? match, value: result?.value } } } } /** * Utility functions for environment variable parsers. */ export const envParserUtils = { /** * Bypass parser that returns the value as is. * @param value The environment variable value. * @returns An object with the original value. */ bypass: (value: string) => ({ value }), /** * Redirects the parsing to another parser for a specific key. * @param parser The target parser to redirect to. * @param key The key to use in the target parser. * @returns A function that takes the environment variable value and returns the parsed result from the target parser. */ redirect: >(parser: Parser, key: Parameters[0]) => (value: string) => parser.parse(key, value) }