@microsoft/agents-hosting
Version:
Microsoft 365 Agents SDK for JavaScript
607 lines (530 loc) • 24.2 kB
text/typescript
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { debug } from '@microsoft/agents-activity/logger'
import { AuthorizationHandlerStatus, AuthorizationHandler, ActiveAuthorizationHandler, AuthorizationHandlerSettings, AuthorizationHandlerTokenOptions } from '../types'
import { MessageFactory } from '../../../messageFactory'
import { CardFactory } from '../../../cards'
import { TurnContext } from '../../../turnContext'
import { TokenExchangeRequest, TokenExchangeInvokeResponse, TokenResponse, UserTokenClient } from '../../../oauth'
import jwt, { JwtPayload } from 'jsonwebtoken'
import { HandlerStorage } from '../handlerStorage'
import { Activity, ActivityTypes, Channels } from '@microsoft/agents-activity'
import { InvokeResponse, TokenExchangeInvokeRequest } from '../../../invoke'
const logger = debug('agents:authorization:azurebot')
const DEFAULT_SIGN_IN_ATTEMPTS = 2
enum Category {
SIGNIN = 'signin',
UNKNOWN = 'unknown',
}
/**
* Active handler manager information.
*/
export interface AzureBotActiveHandler extends ActiveAuthorizationHandler {
/**
* The number of attempts left for the handler to process in case of failure.
*/
attemptsLeft: number
/**
* The current category of the handler.
*/
category?: Category
}
/**
* Messages configuration for the AzureBotAuthorization handler.
*/
export interface AzureBotAuthorizationOptionsMessages {
/**
* Message displayed when an invalid code is entered.
* Use `{code}` as a placeholder for the entered code.
* Defaults to: 'The code entered is invalid. Please sign-in again to continue.'
*/
invalidCode?: string
/**
* Message displayed when the entered code format is invalid.
* Use `{attemptsLeft}` as a placeholder for the number of attempts left.
* Defaults to: 'Please enter a valid **6-digit** code format (_e.g. 123456_).\r\n**{attemptsLeft} attempt(s) left...**'
*/
invalidCodeFormat?: string
/**
* Message displayed when the maximum number of attempts is exceeded.
* Use `{maxAttempts}` as a placeholder for the maximum number of attempts.
* Defaults to: 'You have exceeded the maximum number of sign-in attempts ({maxAttempts}).'
*/
maxAttemptsExceeded?: string
}
/**
* Settings for on-behalf-of token acquisition.
*/
export interface AzureBotAuthorizationOptionsOBO {
/**
* Connection name to use for on-behalf-of token acquisition.
*/
connection?: string
/**
* Scopes to request for on-behalf-of token acquisition.
*/
scopes?: string[]
}
/**
* Interface defining an authorization handler configuration.
*/
export interface AzureBotAuthorizationOptions {
/**
* The type of authorization handler.
* This property is optional and should not be set when configuring this handler.
* It is included here for completeness and type safety.
*/
type?: undefined
/**
* Connection name for the auth provider.
* @remarks
* When using environment variables, this can be set using the `${authHandlerId}_connectionName` variable.
*/
name?: string,
/**
* Title to display on auth cards/UI.
* @remarks
* When using environment variables, this can be set using the `${authHandlerId}_connectionTitle` variable.
*/
title?: string,
/**
* Text to display on auth cards/UI.
* @remarks
* When using environment variables, this can be set using the `${authHandlerId}_connectionText` variable.
*/
text?: string,
/**
* Maximum number of attempts for entering the magic code. Defaults to 2.
* @remarks
* When using environment variables, this can be set using the `${authHandlerId}_maxAttempts` variable.
*/
maxAttempts?: number
/**
* Messages to display for various authentication scenarios.
* @remarks
* When using environment variables, these can be set using the following variables:
* - `${authHandlerId}_messages_invalidCode`
* - `${authHandlerId}_messages_invalidCodeFormat`
* - `${authHandlerId}_messages_maxAttemptsExceeded`
*/
messages?: AzureBotAuthorizationOptionsMessages
/**
* Settings for on-behalf-of token acquisition.
* @remarks
* When using environment variables, these can be set using the following variables:
* - `${authHandlerId}_obo_connection`
* - `${authHandlerId}_obo_scopes` (comma-separated values, e.g. `scope1,scope2`)
*/
obo?: AzureBotAuthorizationOptionsOBO
/**
* Option to enable SSO when authenticating using Azure Active Directory (AAD). Defaults to true.
*/
enableSso?: boolean
}
/**
* Settings for configuring the AzureBot authorization handler.
*/
export interface AzureBotAuthorizationSettings extends AuthorizationHandlerSettings {}
/**
* Interface for token verification state.
*/
interface TokenVerifyState {
state: string
}
/**
* Interface for sign-in failure value.
*/
interface SignInFailureValue {
code: string
message: string
}
/**
* Default implementation of an authorization handler using Azure Bot Service.
*/
export class AzureBotAuthorization implements AuthorizationHandler {
private _options: AzureBotAuthorizationOptions
private _onSuccess?: Parameters<AuthorizationHandler['onSuccess']>[0]
private _onFailure?: Parameters<AuthorizationHandler['onFailure']>[0]
/**
* Creates an instance of the AzureBotAuthorization.
* @param id The unique identifier for the handler.
* @param options The settings for the handler.
* @param app The agent application instance.
*/
constructor (public readonly id: string, options: AzureBotAuthorizationOptions, private settings: AzureBotAuthorizationSettings) {
if (!this.settings.storage) {
throw new Error(this.prefix('The \'storage\' option is not available in the app options. Ensure that the app is properly configured.'))
}
if (!this.settings.connections) {
throw new Error(this.prefix('The \'connections\' option is not available in the app options. Ensure that the app is properly configured.'))
}
this._options = this.loadOptions(options)
}
/**
* Loads and validates the authorization handler options.
*/
private loadOptions (settings: AzureBotAuthorizationOptions) {
const result: AzureBotAuthorizationOptions = {
name: settings.name ?? (process.env[`${this.id}_connectionName`]),
title: settings.title ?? (process.env[`${this.id}_connectionTitle`]) ?? 'Sign-in',
text: settings.text ?? (process.env[`${this.id}_connectionText`]) ?? 'Please sign-in to continue',
maxAttempts: settings.maxAttempts ?? parseInt(process.env[`${this.id}_maxAttempts`]!),
messages: {
invalidCode: settings.messages?.invalidCode ?? process.env[`${this.id}_messages_invalidCode`],
invalidCodeFormat: settings.messages?.invalidCodeFormat ?? process.env[`${this.id}_messages_invalidCodeFormat`],
maxAttemptsExceeded: settings.messages?.maxAttemptsExceeded ?? process.env[`${this.id}_messages_maxAttemptsExceeded`],
},
obo: {
connection: settings.obo?.connection ?? process.env[`${this.id}_obo_connection`],
scopes: settings.obo?.scopes ?? this.loadScopes(process.env[`${this.id}_obo_scopes`]),
},
enableSso: process.env[`${this.id}_enableSso`] !== 'false' // default value is true
}
if (!result.name) {
throw new Error(this.prefix(`The 'name' property or '${this.id}_connectionName' env variable is required to initialize the handler.`))
}
return result
}
/**
* Maximum number of attempts for magic code entry.
*/
private get maxAttempts (): number {
const attempts = this._options.maxAttempts
const result = typeof attempts === 'number' && Number.isFinite(attempts) ? Math.round(attempts) : NaN
return result > 0 ? result : DEFAULT_SIGN_IN_ATTEMPTS
}
/**
* Sets a handler to be called when a user successfully signs in.
* @param callback The callback function to be invoked on successful sign-in.
*/
onSuccess (callback: (context: TurnContext) => Promise<void> | void): void {
this._onSuccess = callback
}
/**
* Sets a handler to be called when a user fails to sign in.
* @param callback The callback function to be invoked on sign-in failure.
*/
onFailure (callback: (context: TurnContext, reason?: string) => Promise<void> | void): void {
this._onFailure = callback
}
/**
* Retrieves the token for the user, optionally using on-behalf-of flow for specified scopes.
* @param context The turn context.
* @param options Optional options for token acquisition, including connection and scopes for on-behalf-of flow.
* @returns The token response containing the token or undefined if not available.
*/
async token (context: TurnContext, options?: AuthorizationHandlerTokenOptions): Promise<TokenResponse> {
let { token } = this.getContext(context)
if (!token?.trim()) {
const { activity } = context
const userTokenClient = await this.getUserTokenClient(context)
// Using getTokenOrSignInResource instead of getUserToken to avoid HTTP 404 errors.
const { tokenResponse } = await userTokenClient.getTokenOrSignInResource(activity.from?.id!, this._options.name!, activity.channelId!, activity.getConversationReference(), activity.relatesTo!, '')
token = tokenResponse?.token
}
if (!token?.trim()) {
return { token: undefined }
}
return await this.handleOBO(token, options)
}
/**
* Signs out the user from the service.
* @param context The turn context.
* @returns True if the signout was successful, false otherwise.
*/
async signout (context: TurnContext): Promise<boolean> {
const user = context.activity.from?.id
const channel = context.activity.channelId
const connection = this._options.name!
if (!channel || !user) {
throw new Error(this.prefix('Both \'activity.channelId\' and \'activity.from.id\' are required to perform signout.'))
}
logger.debug(this.prefix(`Signing out User '${user}' from => Channel: '${channel}', Connection: '${connection}'`), context.activity)
const userTokenClient = await this.getUserTokenClient(context)
await userTokenClient.signOut(user, connection, channel)
return true
}
/**
* Initiates the sign-in process for the handler.
* @param context The turn context.
* @param active Optional active handler data.
* @returns The status of the sign-in attempt.
*/
async signin (context: TurnContext, active?: AzureBotActiveHandler): Promise<AuthorizationHandlerStatus> {
const { activity } = context
const [category] = activity.name?.split('/') ?? [Category.UNKNOWN]
const storage = new HandlerStorage<AzureBotActiveHandler>(this.settings.storage, context)
if (!active) {
return this.setToken(storage, context)
}
logger.debug(this.prefix('Sign-in active session detected'), active.activity)
if (active.activity.conversation?.id !== activity.conversation?.id) {
await this.sendInvokeResponse(context, { status: 400 })
logger.warn(this.prefix('Discarding the active session due to the conversation has changed during an active sign-in process'), activity)
return AuthorizationHandlerStatus.IGNORED
}
if (active.attemptsLeft <= 0) {
logger.warn(this.prefix('Maximum sign-in attempts exceeded'), activity)
await context.sendActivity(MessageFactory.text(this.messages.maxAttemptsExceeded(this.maxAttempts)))
return AuthorizationHandlerStatus.REJECTED
}
if (category === Category.SIGNIN) {
await storage.write({ ...active, category })
const status = await this.handleSignInActivities(context)
if (status !== AuthorizationHandlerStatus.IGNORED) {
return status
}
} else if (active.category === Category.SIGNIN) {
// This is only for safety in case of unexpected behaviors during the MS Teams sign-in process,
// e.g., user interrupts the flow by clicking the Consent Cancel button.
logger.warn(this.prefix('The incoming activity will be revalidated due to a change in the sign-in flow'), activity)
return AuthorizationHandlerStatus.REVALIDATE
}
const { status, code } = await this.codeVerification(storage, context, active)
if (status !== AuthorizationHandlerStatus.APPROVED) {
return status
}
try {
const result = await this.setToken(storage, context, active, code)
if (result !== AuthorizationHandlerStatus.APPROVED) {
await this.sendInvokeResponse(context, { status: 404 })
return result
}
await this.sendInvokeResponse(context, { status: 200 })
await this._onSuccess?.(context)
return result
} catch (error) {
await this.sendInvokeResponse(context, { status: 500 })
if (error instanceof Error) {
error.message = this.prefix(error.message)
}
throw error
}
}
/**
* Handles on-behalf-of token acquisition.
*/
private async handleOBO (token:string, options?: AuthorizationHandlerTokenOptions): Promise<TokenResponse> {
const oboConnection = options?.connection ?? this._options.obo?.connection
const oboScopes = options?.scopes && options.scopes.length > 0 ? options.scopes : this._options.obo?.scopes
if (!oboScopes || oboScopes.length === 0) {
return { token }
}
if (!this.isExchangeable(token)) {
throw new Error(this.prefix('The current token is not exchangeable for an on-behalf-of flow. Ensure the token audience starts with \'api://\'.'))
}
try {
const provider = oboConnection ? this.settings.connections.getConnection(oboConnection) : this.settings.connections.getDefaultConnection()
const newToken = await provider.acquireTokenOnBehalfOf(oboScopes, token)
logger.debug(this.prefix('Successfully acquired on-behalf-of token'), { connection: oboConnection, scopes: oboScopes })
return { token: newToken }
} catch (error) {
logger.error(this.prefix('Failed to exchange on-behalf-of token'), { connection: oboConnection, scopes: oboScopes }, error)
return { token: undefined }
}
}
/**
* Checks if a token is exchangeable for an on-behalf-of flow.
*/
private isExchangeable (token: string | undefined): boolean {
if (!token || typeof token !== 'string') {
return false
}
const payload = jwt.decode(token) as JwtPayload
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]
return audiences.some(aud => typeof aud === 'string' && aud.startsWith('api://'))
}
/**
* Sets the token from the token response or initiates the sign-in flow.
*/
private async setToken (storage: HandlerStorage<AzureBotActiveHandler>, context: TurnContext, active?: AzureBotActiveHandler, code?: string): Promise<AuthorizationHandlerStatus> {
const { activity } = context
const userTokenClient = await this.getUserTokenClient(context)
const { tokenResponse, signInResource } = await userTokenClient.getTokenOrSignInResource(activity.from?.id!, this._options.name!, activity.channelId!, activity.getConversationReference(), activity.relatesTo!, code ?? '')
if (!tokenResponse && active) {
logger.warn(this.prefix('Invalid code entered. Restarting sign-in flow'), activity)
await context.sendActivity(MessageFactory.text(this.messages.invalidCode(code ?? '')))
return AuthorizationHandlerStatus.REJECTED
}
if (!tokenResponse) {
logger.debug(this.prefix('Cannot find token. Sending sign-in card'), activity)
const oCard = CardFactory.oauthCard(this._options.name!, this._options.title!, this._options.text!, signInResource, this._options.enableSso)
await context.sendActivity(MessageFactory.attachment(oCard))
await storage.write({ activity, id: this.id, ...(active ?? {}), attemptsLeft: this.maxAttempts })
return AuthorizationHandlerStatus.PENDING
}
logger.debug(this.prefix('Successfully acquired token'), activity)
this.setContext(context, { token: tokenResponse.token })
return AuthorizationHandlerStatus.APPROVED
}
/**
* Handles sign-in related activities.
*/
private async handleSignInActivities (context: TurnContext): Promise<AuthorizationHandlerStatus> {
const { activity } = context
// Ignore signin/verifyState here (handled in codeVerification).
if (activity.name === 'signin/verifyState') {
return AuthorizationHandlerStatus.IGNORED
}
const userTokenClient = await this.getUserTokenClient(context)
if (activity.name === 'signin/tokenExchange') {
const tokenExchangeInvokeRequest = activity.value as TokenExchangeInvokeRequest
const tokenExchangeRequest: TokenExchangeRequest = { token: tokenExchangeInvokeRequest.token }
if (!tokenExchangeRequest?.token) {
const reason = 'The Agent received an InvokeActivity that is missing a TokenExchangeInvokeRequest value. This is required to be sent with the InvokeActivity.'
await this.sendInvokeResponse<TokenExchangeInvokeResponse>(context, {
status: 400,
body: { connectionName: this._options.name!, failureDetail: reason }
})
logger.error(this.prefix(reason))
await this._onFailure?.(context, reason)
return AuthorizationHandlerStatus.REJECTED
}
if (tokenExchangeInvokeRequest.connectionName !== this._options.name) {
const reason = `The Agent received an InvokeActivity with a TokenExchangeInvokeRequest for a different connection name ('${tokenExchangeInvokeRequest.connectionName}') than expected ('${this._options.name}').`
await this.sendInvokeResponse<TokenExchangeInvokeResponse>(context, {
status: 400,
body: { id: tokenExchangeInvokeRequest.id, connectionName: this._options.name!, failureDetail: reason }
})
logger.error(this.prefix(reason))
await this._onFailure?.(context, reason)
return AuthorizationHandlerStatus.REJECTED
}
const { token } = await userTokenClient.exchangeTokenAsync(activity.from?.id!, this._options.name!, activity.channelId!, tokenExchangeRequest)
if (!token) {
const reason = 'The MS Teams token service didn\'t send back the exchanged token. Waiting for MS Teams to send another signin/tokenExchange request. After multiple failed attempts, the user will be asked to enter the magic code.'
await this.sendInvokeResponse<TokenExchangeInvokeResponse>(context, {
status: 412,
body: { id: tokenExchangeInvokeRequest.id, connectionName: this._options.name!, failureDetail: reason }
})
logger.debug(this.prefix(reason))
return AuthorizationHandlerStatus.PENDING
}
await this.sendInvokeResponse<TokenExchangeInvokeResponse>(context, {
status: 200,
body: { id: tokenExchangeInvokeRequest.id, connectionName: this._options.name! }
})
logger.debug(this.prefix('Successfully exchanged token'))
this.setContext(context, { token })
await this._onSuccess?.(context)
return AuthorizationHandlerStatus.APPROVED
}
if (activity.name === 'signin/failure') {
await this.sendInvokeResponse(context, { status: 200 })
const reason = 'Failed to sign-in'
const value = activity.value as SignInFailureValue
logger.error(this.prefix(reason), value, activity)
if (this._onFailure) {
await this._onFailure(context, value.message || reason)
} else {
await context.sendActivity(MessageFactory.text(`${reason}. Please try again.`))
}
return AuthorizationHandlerStatus.REJECTED
}
logger.error(this.prefix(`Unknown sign-in activity name: ${activity.name}`), activity)
return AuthorizationHandlerStatus.REJECTED
}
/**
* Verifies the magic code provided by the user.
*/
private async codeVerification (storage: HandlerStorage<AzureBotActiveHandler>, context: TurnContext, active?: AzureBotActiveHandler): Promise<{ status: AuthorizationHandlerStatus, code?: string }> {
if (!active) {
logger.debug(this.prefix('No active session found. Skipping code verification.'), context.activity)
return { status: AuthorizationHandlerStatus.IGNORED }
}
const { activity } = context
let state: string | undefined = activity.text
if (activity.name === 'signin/verifyState') {
logger.debug(this.prefix('Getting code from activity.value'), activity)
const { state: teamsState } = activity.value as TokenVerifyState
state = teamsState
}
if (state === 'CancelledByUser') {
await this.sendInvokeResponse(context, { status: 200 })
logger.warn(this.prefix('Sign-in process was cancelled by the user'), activity)
return { status: AuthorizationHandlerStatus.REJECTED }
}
if (!state?.match(/^\d{6}$/)) {
logger.warn(this.prefix(`Invalid magic code entered. Attempts left: ${active.attemptsLeft}`), activity)
await context.sendActivity(MessageFactory.text(this.messages.invalidCodeFormat(active.attemptsLeft)))
await storage.write({ ...active, attemptsLeft: active.attemptsLeft - 1 })
return { status: AuthorizationHandlerStatus.PENDING }
}
await this.sendInvokeResponse(context, { status: 200 })
logger.debug(this.prefix('Code verification successful'), activity)
return { status: AuthorizationHandlerStatus.APPROVED, code: state }
}
private _key = `${AzureBotAuthorization.name}/${this.id}`
/**
* Sets the authorization context in the turn state.
*/
private setContext (context: TurnContext, data: TokenResponse) {
return context.turnState.set(this._key, () => data)
}
/**
* Gets the authorization context from the turn state.
*/
private getContext (context: TurnContext): TokenResponse {
const result = context.turnState.get(this._key)
return result?.() ?? { token: undefined }
}
/**
* Gets the user token client from the turn context.
*/
private async getUserTokenClient (context: TurnContext): Promise<UserTokenClient> {
const userTokenClient = context.turnState.get<UserTokenClient>(context.adapter.UserTokenClientKey)
if (!userTokenClient) {
throw new Error(this.prefix('The \'userTokenClient\' is not available in the adapter. Ensure that the adapter supports user token operations.'))
}
return userTokenClient
}
/**
* Sends an InvokeResponse activity if the channel is Microsoft Teams.
*/
private sendInvokeResponse <T>(context: TurnContext, response: InvokeResponse<T>) {
if (context.activity.channelId !== Channels.Msteams) {
return Promise.resolve()
}
return context.sendActivity(Activity.fromObject({
type: ActivityTypes.InvokeResponse,
value: response
}))
}
/**
* Prefixes a message with the handler ID.
*/
private prefix (message: string) {
return `[handler:${this.id}] ${message}`
}
/**
* Predefined messages with dynamic placeholders.
*/
private messages = {
invalidCode: (code: string) => {
const message = this._options.messages?.invalidCode ?? 'Invalid **{code}** code entered. Please try again with a new sign-in request.'
return message.replaceAll('{code}', code)
},
invalidCodeFormat: (attemptsLeft: number) => {
const message = this._options.messages?.invalidCodeFormat ?? 'Please enter a valid **6-digit** code format (_e.g. 123456_).\r\n**{attemptsLeft} attempt(s) left...**'
return message.replaceAll('{attemptsLeft}', attemptsLeft.toString())
},
maxAttemptsExceeded: (maxAttempts: number) => {
const message = this._options.messages?.maxAttemptsExceeded ?? 'You have exceeded the maximum number of sign-in attempts ({maxAttempts}). Please try again with a new sign-in request.'
return message.replaceAll('{maxAttempts}', maxAttempts.toString())
},
}
/**
* Loads the OAuth scopes from the environment variables.
*/
private loadScopes (value:string | undefined): string[] {
return value?.split(',').reduce<string[]>((acc, scope) => {
const trimmed = scope.trim()
if (trimmed) {
acc.push(trimmed)
}
return acc
}, []) ?? []
}
}