Here we’ll create a Spring Boot web app that can create a Teams channel and invite a list of stakeholders. The app will also offer a simple UI that an SRE or other incident commander can use to publish status updates to the channel on their behalf with a single click – making it easy for the incident commander to keep stakeholders up to date without pulling themselves out of their workflow to type chat messages.
Communication is key when critical systems go down. Support teams need to keep customers informed, product owners need to understand the impact of outages, and engineering teams need to coordinate their efforts.
Chat tools like Microsoft Teams provide a natural collaboration point. But unstructured conversation forces stakeholders to read back through pages of chat messages to try and infer the state of any remediation work. Or, it could result in having to interrupt those currently solving the issue with explicit status requests.
An incident management bot provides a solution by allowing a limited number of agreed-upon status messages to be posted to a chat. This allows stakeholders to instantly understand the state of remediation work.
In the first article of this series, we created an authentication provider using the Microsoft Graph SDK for Java. In the second article, we used OneNote to Markdown with Java and the Microsoft Graph SDK. In this third and final article of the series, we’ll create a simple incident management bot with MSAL, Microsoft Teams, the Microsoft Graph API, and Spring Boot.
The Sample Application
The source code for this sample application can be found in the GitHub MSALIncidentManagementDemo repo.
Combine Front-End and Back-End Logic
This sample application exposes the web front-end and OAuth resource server in a single application. This approach is described in the Microsoft documentation.
Bootstrap the Spring Project
We’ll generate the initial application template using Spring Initalizr to create a Java Maven project generating a JAR file using the latest non-snapshot version of Spring against Java 11.
The project — a combined web application and resource server — requires the following dependencies:
Configure Spring and Azure AD
Our application.yaml file contains the details of the Azure AD application.
azure:
activedirectory:
tenant-id: ${TENANT_ID}
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}
app-id-uri: ${APP_ID_URI}
application-type: web_application_and_resource_server
authorization-clients:
api:
authorizationGrantType: authorization_code
scopes:
- api://d21b7691-b12b-4a9a-a35e-542a0a577f78/Teams
The application-type
property must be set to web_application_and_resource_server
when building a combined web application and resource server.
application-type: web_application_and_resource_server
Also, note that we create an authorized client that grants access back to the same application. This client is how the web application controller communicates with the resource server controller:
authorization-clients:
api:
authorizationGrantType: authorization_code
scopes:
- api://d21b7691-b12b-4a9a-a35e-542a0a577f78/Teams
Configure Spring Security
We configure Spring Security through the AuthSecurityConfig class, in the following package:
package com.matthewcasperson.incidentmanagementdemo.configuration;
Our security class nests two static classes.
The first is the ApiWebSecurityConfigurationAdapter
class, which configures the endpoints exposed by the OAuth resource server. This class extends the AADResourceServerWebSecurityConfigurerAdapter
class, overrides the configure
method, and calls the base configure
method to allow AADResourceServerWebSecurityConfigurerAdapter
to initialize common resource server security rules.
@EnableWebSecurity
public class AuthSecurityConfig {
@Order(1)
@Configuration
public static class ApiWebSecurityConfigurationAdapter extends
AADResourceServerWebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
We then require all requests to endpoints prefixed with /api
to be authenticated.
http
.antMatcher("/api/**")
.authorizeRequests().anyRequest().authenticated();
}
}
The HtmlWebSecurityConfigurerAdapter
class configures the endpoints exposed by the webserver. This class extends the AADWebSecurityConfigurerAdapter
class, overrides the configure
method, and calls the base configure
method to allow AADWebSecurityConfigurerAdapter
to initialize common web application security rules.
@Configuration
public static class HtmlWebSecurityConfigurerAdapter extends AADWebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
We configure rules to allow static assets like JavaScript and CSS files, as well as the login page, to be requested without authentication. We then require authentication for requests to all other endpoints, and disable Cross Site Request Forgery (CSRF) to simplify the development of the sample application:
http
.authorizeRequests()
.antMatchers("/login", "/*.js", "/*.css").permitAll()
.anyRequest().authenticated()
.and()
.csrf()
.disable();
}
}
}
Create the Graph API Client
We will access the Microsoft Graph API via the official Graph API client. First, we expose an instance of the client as a bean in the GraphClientConfiguration class:
package com.matthewcasperson.incidentmanagementdemo.providers;
We inject an instance of the AADAuthenticationProperties
class, providing easy access to the configuration values in our application.yml file.
@Configuration
public class GraphClientConfiguration {
@Autowired
AADAuthenticationProperties azureAd;
The client is created in the getClient
method. We make use of the OboAuthenticationProvider
class introduced in a previous article to generate an on-behalf-of (OBO) token suitable for accessing the Graph API. The list of scopes contains the permissions required to list and create channels, assign users to channels, and create messages in channels:
@Bean
public GraphServiceClient<Request> getClient() {
return GraphServiceClient.builder()
.authenticationProvider(new OboAuthenticationProvider(
Set.of("https://graph.microsoft.com/Channel.Create",
"https://graph.microsoft.com/ChannelSettings.Read.All",
"https://graph.microsoft.com/ChannelMember.ReadWrite.All",
"https://graph.microsoft.com/ChannelMessage.Send",
"https://graph.microsoft.com/Team.ReadBasic.All",
"https://graph.microsoft.com/TeamMember.ReadWrite.All",
"https://graph.microsoft.com/User.ReadBasic.All"),
azureAd.getTenantId(),
azureAd.getClientId(),
azureAd.getClientSecret()))
.buildClient();
}
}
The OBO Token Provider
The OboAuthenticationProvider class was covered in detail in a previous article. The code we use here will be identical, except for the package statement:
package com.matthewcasperson.incidentmanagementdemo.providers;
At a high level, this class extracts the JWT passed to the OAuth resource server endpoints and exchanges it for an OBO token with a given set of scopes.
Create the WebClient
Even though we’re exposing a web application and OAuth resource server with a single Spring Boot application, the webserver accesses the resource server endpoints like any other external client using HTTP requests containing the appropriate OAuth authentication headers.
We need a WebClient
for the frontend application to interact with the resource server. WebClient
is the new non-blocking solution for making HTTP calls and is the preferred option over the older RestTemplate
.
To call the resource server endpoints, each request needs to have an associated access token. The WebClientConfig
class code configures an instance of WebClient
to include a token sourced from an OAuth2AuthorizedClient
:
package com.matthewcasperson.incidentmanagementdemo.providers;
...
...
@Configuration
public class WebClientConfiguration {
@Bean
public static WebClient webClient(final OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager) {
final ServletOAuth2AuthorizedClientExchangeFilterFunction function =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(oAuth2AuthorizedClientManager);
return WebClient.builder()
.apply(function.oauth2Configuration())
.build();
}
}
Create the Web Application Controller
The web application endpoints are exposed by the IncidentWebController class, detailed in the following package:
package com.matthewcasperson.incidentmanagementdemo.controller;
The front end interacts with the resource server endpoints using the WebClient
we created in the WebClientConfiguration
class. We inject an instance of WebClient
into our controller:
@Controller
public class IncidentWebController {
@Autowired
WebClient webClient;
The root directory is exposed by the getCreateChannel
method. This method injects a client called api
that’s configured to access the resource server:
@GetMapping("/")
public ModelAndView getCreateChannel(
@RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client) {
final ModelAndView mav = new ModelAndView("create");
The page requires a list of teams and users, which are retrieved from the resource server endpoints.
final List teams = webClient
.get()
.uri("http://localhost:8080/api/teams/")
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.bodyToMono(List.class)
.block();
final List users = webClient
.get()
.uri("http://localhost:8080/api/users/")
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.bodyToMono(List.class)
.block();
The users and teams are added as model attributes, and the ModelAndView
object is returned:
mav.addObject("teams", teams);
mav.addObject("users", users);
return mav;
}
The Thymeleaf template renders the teams and users in lists and provides a text box where we can define the name of the new incident management channel.
The form above submits a POST request to the channel endpoint. This is handled by the postCreateChannel
method.
@PostMapping("/channel")
public ModelAndView postCreateChannel(
@RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client,
@RequestParam final String channelName,
@RequestParam final String team,
@RequestParam final List<String> users) {
The browser is redirected to the message
page when this method returns:
final ModelAndView mav = new ModelAndView("redirect:/message");
The new channel is created and assigned to the selected users by calling the resource server.
final Channel newChannel = webClient
.post()
.uri("http://localhost:8080/api/teams/" + team + "/channel")
.bodyValue(new IncidentRestController.NewChannelBody(channelName, users))
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.bodyToMono(Channel.class)
.block();
The next page requires the name and ID of the new channel, as well as the team ID. These are defined as model attributes. The ModelAndView is then returned:
mav.addObject("channelId", newChannel.id);
mav.addObject("channelName", newChannel.displayName);
mav.addObject("team", team);
return mav;
}
When redirecting the browser to a new page, Spring adds the model attributes as query parameters. The getCreateMessage
method handles the GET request to the message page, extracts the query parameters, and redefines them in a new ModelAndView
so they can be consumed by the Thymeleaf template.
@GetMapping("/message")
public ModelAndView getCreateMessage(
@QueryParam("team") final String team,
@QueryParam("channel") final String channelId,
@QueryParam("channel") final String channelName) {
final ModelAndView mav = new ModelAndView("message");
mav.addObject("team", team);
mav.addObject("channelId", channelId);
mav.addObject("channelName", channelName);
return mav;
}
The message
page displays a form that allows users to enter and submit a custom message to the channel, or use a predefined status message. These status messages would be configured to the business’ needs and represent the standard lifecycle of an incident.
By posting these standard messages to the incident management channel, the engineering and support teams have a convenient communication platform. Updates only require a single button press, and all parties have a shared understanding of the state of the incident.
The form performs a POST request to the message
endpoint, which is handled by the postCreateMessage
method.
@PostMapping("/message")
public ModelAndView postCreateMessage(
@RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client,
@RequestParam final String channelName,
@RequestParam final String channelId,
@RequestParam final String team,
@RequestParam final String customMessage,
@RequestParam final String status) {
final ModelAndView mav = new ModelAndView("message");
The resource server is responsible for creating the new messages in Teams:
webClient
.post()
.uri("http://localhost:8080/api/teams/" + team + "/channel/" + channelId + "/message")
.bodyValue(status + "\nMessage: " + customMessage)
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.toBodilessEntity()
.block();
The same form is then redisplayed to allow the next status message to be posted:
mav.addObject("channelName", channelName);
mav.addObject("channelId", channelId);
mav.addObject("team", team);
return mav;
}
}
Last, the custom message and status are posted to the new channel.
Create the Resource Server Controller
The resource server is exposed by the IncidentRestController class, found in the following package:
package com.matthewcasperson.incidentmanagementdemo.controller;
These are REST API endpoints, so our controller is annotated with @RestController
.
@RestController
public class IncidentRestController {
The information required to create a new channel and add users is defined by the NewChannelBody
class:
public static class NewChannelBody {
public final String channelName;
public final List<String> members;
public NewChannelBody(
final String channelName,
final List<String> members) {
this.channelName = channelName;
this.members = members;
}
}
Access to the Graph API is performed through the client created by the GraphClientConfiguration
class. An instance of this client is injected here:
@Autowired
GraphServiceClient<Request> client;
The getTeams
method returns a list of teams.
A common pattern when interacting with the Graph API client is to use the Optional
class to deal with possible null
values being returned. Our app assumes a null
value represents an empty list, but the production code would have more robust error handling for these situations.
@GetMapping("/api/teams")
public List<Team> getTeams() {
return Optional.ofNullable(client
.me()
.joinedTeams()
.buildRequest()
.get())
.map(BaseCollectionPage::getCurrentPage)
.orElse(List.of());
}
The getUsers
method returns a list of users.
@GetMapping("/api/users")
public List<User> getUsers() {
return Optional.ofNullable(client
.users()
.buildRequest()
.get())
.map(BaseCollectionPage::getCurrentPage)
.orElse(List.of());
}
We create a new channel in the createChannel
method.
@PostMapping("/api/teams/{team}/channel")
public Channel createChannel(
@PathVariable("team") final String team,
@RequestBody final NewChannelBody newChannelBody) {
final Channel channel = new Channel();
channel.displayName = newChannelBody.channelName;
channel.membershipType = ChannelMembershipType.PRIVATE;
Creating a new channel involves several operations. We start by querying the Graph API for any existing channel with the same name:
final List<Channel> existingChannel = Optional.ofNullable(client
.teams(team)
.channels()
.buildRequest()
.filter("displayName eq '" + newChannelBody.channelName + "'")
.get())
.map(BaseCollectionPage::getCurrentPage)
.orElse(List.of());
We then either reuse the existing channel or create a new one.
final Channel newChannel = existingChannel.isEmpty()
? client
.teams(team)
.channels()
.buildRequest()
.post(channel)
: existingChannel.get(0);
Each of the selected users is then added to the team and then to the channel:
for (final String memberId : newChannelBody.members) {
final ConversationMember member = new ConversationMember();
member.oDataType = "#microsoft.graph.aadUserConversationMember";
member.additionalDataManager().put(
"user@odata.bind",
new JsonPrimitive("https://graph.microsoft.com/v1.0/users('" +
URLEncoder.encode(memberId, StandardCharsets.UTF_8) + "')"));
try {
client
.teams(team)
.members()
.buildRequest()
.post(member);
client
.teams(team)
.channels(newChannel.id)
.members()
.buildRequest()
.post(member);
} catch (final Exception ex) {
System.out.println(ex);
ex.printStackTrace();
}
}
The channel is then returned.
return newChannel;
}
Creating a new message is handled by the createMessage
method:
@PostMapping("/api/teams/{team}/channel/{channel}/message")
public void createMessage(
@PathVariable("team") final String team,
@PathVariable("channel") final String channel,
@RequestBody final String message) {
We assume the incoming message is plain text, as our forms don’t expose any rich text editing. Since messages are posted using HTML, line breaks are replaced with HTML break elements:
final ChatMessage chatMessage = new ChatMessage();
chatMessage.body = new ItemBody();
chatMessage.body.content = message.replaceAll("\n", "<br/>");
chatMessage.body.contentType = BodyType.HTML;
The message is then posted to the channel.
client
.teams(team)
.channels(channel)
.messages()
.buildRequest()
.post(chatMessage);
}
}
Conclusion
While chat platforms like Teams are convenient for general collaboration, freeform conversations aren’t a great method for conveying the status of an ongoing incident. Anyone trying to quickly grasp the current state of incident resolution work is forced to read and interpret long conversations and nested threads. By creating a limited chat interface with several predetermined status messages, teams can keep stakeholders informed with a high signal-to-noise incident management channel.
In this article, we created an example Spring Boot application that used the MSAL library and Graph API client to quickly create a new channel in Teams, add the required users, and then send predetermined status messages.
Throughout this three-part series, we’ve explored how we can use the Microsoft Identity platform and the Microsoft Authentication Library (MSAL) to build apps in the Microsoft Graph in Spring Cloud Applications. The Microsoft Graph platform and MSAL are easy for us Java developers to use, making them a strong choice for all our authentication and authorization needs.
Let Microsoft handle the painful parts of authentication and authorization so that you can focus on the parts of your applications that add the most business value. Now that we’ve explored how to use the Microsoft Identity library, MSAL, and the Microsoft Graph, start using them in your own applications.