Here we'll demonstrate how to create a Spring Boot web app in Java that replicates the functionality of the Personal Tab with SSO sample app on GitHub.
Convincing teams to adopt additional applications can be challenging. TheMicrosoft Teams for Java Developers series introduced building applications within Microsoft Teams as a solution to this common obstacle. Why not build new tools using platforms people already use every day?
Since your coworkers are already collaborating in Teams, it’s easy to get them to adopt new functions and boost their productivity. They simply select their desired tab and launch the application in an iframe without leaving Teams. In this new three-part series, I’ll demonstrate how you can use Microsoft’s library of sample Teams applications as a framework for building custom Java applications.
In this series, we’ll build all our applications from C# samples. Developers can make the examples using Visual Studio and publish them to Azure App Service. I’ll show you how to reimplement them in Java using Spring Boot.
We’ll use the Eclipse integrated development environment (IDE), but you can use IntelliJ or any other IDE of your choice. I want to run the C# and Java versions side-by-side, so I’ll host both versions on Azure App Service. Note that I don’t rely on ngrok for this article. The Azure App Service servers run the application differently from the Spring Boot package’s Tomcat server, making this a great opportunity to broaden your experience.
Let’s build off the personal to-do list application created in theMicrosoft Teams for Java Developers series. It would be nice to secure this list, so in this tutorial, I’ll build the shell of a personal tab application and secure it with a single sign-on (SSO) via Azure Active Directory (Azure AD). You can then add the to-do list code to this shell, or use it to hold your own application.
Implementing SSO in a Teams Application
We implement single sign-on (SSO) in Teams using OAuth 2. The OAuth 2 authentication process is complex, but I’ve provided all the code you need to handle it. If you aren’t familiar with OAuth 2, you should review this Microsoft article.
This application implements OAuth 2 using a three-part process with help from the Spring Boot OAuth2 Client and the Spring Boot Azure Active Directory library. First, it authenticates the user, then authenticates the application, and finally obtains a token to access Teams resources.
Microsoft specifies that Teams applications should attempt to obtain access "silently" first because Teams can authenticate the user from multiple devices. For example, the user may have an active session on their phone, then open a Teams tab on a tablet. If there’s an authentication issue, the application needs to provide a way for the user to start the authentication process.
When Teams opens my application in an iframe, the application uses the Teams SDK to check for authentication. Note that Teams may respond to the application’s authentication token request by presenting an authentication page to the user.
This process gives us a token to establish the user’s identity, but it doesn’t allow us access to Teams resources. The application must send a request to access Teams resources. The application includes our identity token in that request, and Teams uses it to see if the user has access to the requested resources. If they do, then Teams returns a new token that we can use to request the resources. Most of the code to do this is in auth.js with support from code in the SsoAuthHelper
class.
Registering the Application and Creating an Endpoint
Our application can’t send an access request to Teams unless it has access to Teams. We implement this by registering our application on the Azure Active Directory App Registrations portal and creating a secret that the application uses to authenticate to Azure. This registration must also give Teams access to return data to an application endpoint. Step-by-step instructions for this process are in the Teams Tab SSO Authentication README. You can use this same process to register your Java application.
Note that the registration process asks for the web application’s URL and host domain. Unfortunately, that URL and domain don’t exist yet because we haven’t deployed the application to Azure App Service. But that’s not a problem — we can register the application with a placeholder URL and then update the registration after deploying the application.
Creating the Application Using Spring Initializr
Now that we’ve registered an application, it’s time to build it. Spring provides the Spring Boot Initializr tool, which creates a Maven package that we can import into Eclipse.
Run the tool by opening a browser and going to https://start.spring.io/. Then, fill out the project details and metadata. Include the following dependencies:
- Spring Web
- Thymeleaf
- Azure Active Directory
- OAuth2 Client
- Spring Boot DevTools
Click Generate to create a ZIP file that provides the complete Maven infrastructure and initial runtime class. Unzip the resources into your project directory, then import the directory into Eclipse as an existing Maven project.
In Eclipse, select File > Import to display this dialog:
Select Existing Maven Projects and click Next to display this dialog:
Select the directory to which you unzipped the file downloaded from Spring Initializr, check the box next to your project, and click Finish.
After you have imported the project, enable the Bootstrap CSS by adding this dependency to the pom.xml file:
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>4.0.0-2</version>
</dependency>
The complete project is available on GitHub.
Preparing the Core Java Code
The C# sample uses the Razor MVC framework, but I’ve stripped it to minimal implementation. This minimal version emphasizes what you need for SSO and enables you to add what your web application needs. Because of this, these classes, aligning to C# files, support the application:
Java Class File | C# Class Source File |
AppConfiguration.java | Startup.cs |
PersonalTabSsoApplication.java | Program.cs |
HomeController.java | HomeController.cs |
AuthController.java | AuthController.cs |
SSOAuthHelper.java | SSOAuthHelper.cs |
When Spring Boot starts our application, it runs main
in the PersonalTabSsoApplication
class. This class uses the default class implementation that the Spring Boot starter provides.
The AppConfiguration
class configures the Spring Boot engine. We must remember that Teams launches our application in an iframe. Also, the web application server that Azure App Service uses enforces cross-frame scripting security, so we need to add code to allow our application to run. The entire class is below:
package com.contentlab.teams.java.personaltabsso;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@Order(value = 0)
public class AppConfiguration extends WebSecurityConfigurerAdapter
{
@Override
public void configure(HttpSecurity http) throws Exception
{
http.headers()
.contentSecurityPolicy(
"frame-ancestors 'self' https://*.microsoftonline.com https://*.microsoft.com;"
);
}
}
Mozilla states that the content-security-policy
header obsoletes the X-FRAME-OPTIONS
header, so I’ve implemented the newer method in this application.
HomeController
provides mappings and handlers for the index
page and the /Auth/Start
and Auth/End
URLs that the SSO process needs. I annotate this class with @Controller
because the response contains web pages.
The AuthController
class provides mapping and a handler for the GetUserAccessToken
URL. I annotate this controller with @RestController
because it gives a text response to the caller. The code in auth.js fetches this URL when the SSO sequence exchanges the user ID token for the user access token.
The SsoAuthHelper
class provides the GetAccessTokenOnBehalfUser
, which AuthController.GetUserAccessToken
calls. This method handles accessing Teams resources on behalf of the authenticated user. It obtains several key values from the applications.properties
file via the env
argument that is passed in:
String clientId = env.getProperty(ClientIdConfigurationSettingsKey);
String tenantId = env.getProperty(TenantIdConfigurationSettingsKey);
String clientSecret = env.getProperty(AppsecretConfigurationSettingsKey);
String issuerUrl = env.getProperty(AzureAuthUrlConfigurationSettingsKey);
String instancerUrl = env.getProperty(AzureInstanceConfigurationSettingsKey);
String azureScopes = env.getProperty(AzureScopes);
The values are obtained from several sources in Azure:
Value | Location |
clientId | Created in Azure when you register you application |
tenantId | Assigned by Azure. You can find it on in the Default Directory Overview page in the Azure Active Directory service |
clientSecret | Created in Azure when you register you application |
issuerUrl | Hard-coded/oauth2/v2.0/token |
instancerUrl | Hard-codedhttps://login.microsoftonline.com |
azureScopes | Hard-codedhttps://graph.microsoft.com/User.Read |
You’ll see all these values later when I cover updating the application.properties file. The last three values are related to services defined by the Microsoft Graph API.
The process of obtaining the token starts by building the URL for a POST request:
URL url = new URL(instancerUrl + "/" + tenantId + issuerUrl);
which resolves to:
https://login.microsoftonline.com/<<your-tenant-ID>>/oauth2/v2.0/token
The body of the request is the following form-encoded data:
String body = "assertion=" + idToken
+ "&requested_token_use=on_behalf_of"
+ "&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer"
+ "&client_id=" + clientId + "@" + tenantId
+ "&client_secret=" + clientSecret
+ "&scope=" + azureScopes;
To understand how GetAccessTokenOnBehalfUser
processes the response to the POST request, we need to examine how the calling function behaves. Here’s the relevant code fragment in auth.js that processes the response:
.then((responseJson) => {
if (IsValidJSONString(responseJson)) {
console.log("valid responseJson: " + responseJson);
var parseResult = JSON.parse(responseJson);
if (JSON.parse(responseJson).error)
reject(JSON.parse(responseJson).error);
} else if (responseJson) {
accessToken = responseJson;
console.log("Exchanged token: " + accessToken);
getUserInfo(context.userPrincipalName);
getPhotoAsync(accessToken);
}
As shown below, when Teams responds with an HTTP 200 status GetAccessTokenOnBehalfUser
extracts the JSON Web Token (JWT) from the response and returns the token. This returned value must not be valid JSON (it’s a valid string, but not valid JSON).
if (http.getResponseCode() == HttpURLConnection.HTTP_OK)
{
InputStream is = http.getInputStream();
String responseBody = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
is.close();
ObjectMapper mapper = new ObjectMapper();
JsonNode actualObj = mapper.readTree(responseBody);
JsonNode accessToken = actualObj.get("access_token");
retval = accessToken.asText();
}
else
{
try
{
InputStream is = http.getInputStream();
retval = new String(is.readAllBytes(), StandardCharsets.UTF_8);
is.close();
}
catch(Exception e)
{
try
{
InputStream errIs = http.getErrorStream();
retval = new String(errIs.readAllBytes(),
StandardCharsets.UTF_8);
errIs.close();
}
catch(Exception ex)
{
retval += " Can't read either input or error stream: "
+ ex.getMessage();
}
}
throw new Exception(retval);
}
Generally, as shown in the else
case above, if Teams returns an error, check the input
stream. If that’s empty, that will fail by throwing an exception, so check the error
stream.
Specifically, Teams returns an HTTP 400 "bad request" error status to indicate an authentication problem. In this case, the input stream will be empty, and the error stream will contain valid JSON data indicating an "invalid grant" error.
GetAccessTokenOnBehalfUser
only returns a valid JSON object when an error occurs. In the fragment above from auth.js, this causes the reject
line to execute:
reject(JSON.parse(responseJson).error)
In turn, this causes the application to present an Authenticate button enabling the user to log in. Otherwise, when a token is returned, the token is not valid JSON and auth.js calls getUserInfo
and getPhotoAsync
.
Preparing the Web Pages
Implement the client code in one JavaScript file called auth.js and three HTML files, which correspond to the following .cshtml files:
Java Application HTML File | CSharp HTML Source File |
index.html | Home/index.cshtml |
/Auth/Start.html | Auth/Start.cshtml |
/Auth/End.html | Auth/End.cshtml |
The index.html web page has a link to auth.js and some HTML to display information about the authenticated user. We provide this to verify that authentication occurred. The index page also provides a button that the user can click to start the authentication process if needed. The user may need to do this if the current authentication time expires.
The Auth/Start.html and Auth/End.html files provide the code to initiate an OAuth 2 flow. Start.html makes a call to https://login.microsoftonline.com/common/oauth2/authorize and includes the URL to /Auth/End.html as the callback. This call triggers the OAuth 2 flow and, when it’s complete, /Auth/End.html receives either an error or an access token.
Adding the Supporting JavaScript
The auth.js file handles the OAuth 2 process. This file, which I lifted directly from the Microsoft C# sample, starts with a jQuery onready
handler that runs as soon as possible after page loading starts.
This handler initializes the Microsoft Teams SDK and calls getClientSideToken
. If that call is successful, and an authentication token returns, getClientSideToken
calls getServerSideToken
.
If that action is successful, getServerSideToken
calls getUserInfo
and getPhotoAsync
, which provide the user information on the index
page. Your application needs to modify getServerSideToken
to provide your initial presentation.
Deploying the Application
Maven provides the tools to create an Azure App Service plan and to deploy the application there. To use these tools, first, open an MS-DOS command prompt (cmd) window. Change the directory (cd
) to the project directory containing the pom.xml file. Then, execute these commands:
az login --tenant 26e17e8f-8a99-44f3-a027-54de7b19f3af
mvn com.microsoft.azure:azure-webapp-maven-plugin:2.2.0:config
mvn package azure-webapp:deploy
The az login
command logs you into Azure so Maven can create resources. A successful result looks like this:
Z:\...\PersonalTabSSO>az login --tenant <<Your tenancy ID goes here>>
The default web browser has been opened at https://login.microsoftonline.com/26...af/oauth2/authorize. Please continue the login in the web browser. If no web browser is available or if the web browser fails to open, use device code flow with `az login --use-device-code`.
You have logged in. Now let us find all the subscriptions to which you have access...
[
{
"cloudName": "AzureCloud",
"homeTenantId": "26...af",
"id": "a3...2e",
"isDefault": true,
"managedByTenants": [],
"name": "AzureJava",
"state": "Enabled",
"tenantId": "26...af",
"user": {
"name": "j<"your user ID appears here,
"type": "user"
}
}
]
The first mvn
command captures the information to create the Azure App Service plan and stores it in pom.xml. A successful response looks like this:
Z:\...\PersonalTabSSO>mvn com.microsoft.azure:azure-webapp-maven-plugin:2.2.0:config
[INFO] Scanning for projects...
[INFO]
[INFO] --------------< com.contentlab.teams.java:PersonalTabSSO >--------------
[INFO] Building PersonalTabSSO 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- azure-webapp-maven-plugin:2.2.0:config (default-cli) @ PersonalTabSSO ---
Auth type: AZURE_CLI
Default subscription: AzureJava(a3...2e)
Username: <<your user ID>>
[INFO] Subscription: AzureJava(a3...2e)
[INFO] It may take a few minutes to load all Java Web Apps, please be patient.
Java SE Web Apps in subscription AzureJava:
* 1: <create>
Please choose a Java SE Web App [<create>]:
Define value for OS [Linux]:
1: Windows
* 2: Linux
3: Docker
Enter your choice: 2
Define value for javaVersion [Java 8]:
* 1: Java 8
2: Java 11
Enter your choice: 2
Define value for pricingTier [P1v2]:
1: B1
2: B2
3: B3
4: D1
5: EP1
6: EP2
7: EP3
8: F1
* 9: P1v2
10: P1v3
11: P2v2
12: P2v3
13: P3v2
14: P3v3
15: S1
16: S2
17: S3
18: Y1
Enter your choice: 8
Please confirm webapp properties
Subscription Id : a3...2e
AppName : PersonalTabSSO-1635023142497
ResourceGroup : PersonalTabSSO-1635023142497-rg
Region : westus
PricingTier : F1
OS : Linux
Java : Java 11
Web server stack: Java SE
Deploy to slot : false
Confirm (Y/N) [Y]: y
[INFO] Saving configuration to pom.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 52.517 s
[INFO] Finished at: 2021-10-23T17:05:59-04:00
[INFO] ------------------------------------------------------------------------
The second mvn
command deploys the application to this plan. A successful result looks like this:
Z:\...\PersonalTabSSO>mvn package azure-webapp:deploy
[INFO] Scanning for projects...
[INFO]
[INFO] --------------< com.contentlab.teams.java:PersonalTabSSO >--------------
[INFO] Building PersonalTabSSO 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-resources-plugin:3.2.0:resources (default-resources) @ PersonalTabSSO ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Using 'UTF-8' encoding to copy filtered properties files.
[INFO] Copying 1 resource
[INFO] Copying 4 resources
[INFO]
[INFO] --- maven-compiler-plugin:3.8.1:compile (default-compile) @ PersonalTabSSO ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-resources-plugin:3.2.0:testResources (default-testResources) @ PersonalTabSSO ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Using 'UTF-8' encoding to copy filtered properties files.
[INFO] skip non existing resourceDirectory Z:\...\PersonalTabSSO\src\test\resources
[INFO]
[INFO] --- maven-compiler-plugin:3.8.1:testCompile (default-testCompile) @ PersonalTabSSO ---
[INFO] Nothing to compile - all classes are up to date
[INFO]
[INFO] --- maven-surefire-plugin:2.22.2:test (default-test) @ PersonalTabSSO ---
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests
18:04:17.121 [main] DEBUG org.springframework.test.context.BootstrapUtils - Instantiating CacheAwareContextLoaderDelegate from class [org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate]
18:04:17.475 [main] DEBUG org.springframework.test.context.BootstrapUtils - Instantiating BootstrapContext using constructor [public org.springframework.test.context.support.DefaultBootstrapContext(java.lang.Class,org.springframework.test.context.CacheAwareContextLoaderDelegate)]
18:04:17.889 [main] DEBUG org.springframework.test.context.BootstrapUtils - Instantiating TestContextBootstrapper for test class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests] from class [org.springframework.boot.test.context.SpringBootTestContextBootstrapper]
18:04:18.073 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper - Neither @ContextConfiguration nor @ContextHierarchy found for test class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests], using SpringBootContextLoader
18:04:18.113 [main] DEBUG org.springframework.test.context.support.AbstractContextLoader - Did not detect default resource location for test class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests]: class path resource [com/contentlab/teams/java/PersonalTabSSO/PersonalTabSsoApplicationTests-context.xml] does not exist
18:04:18.128 [main] DEBUG org.springframework.test.context.support.AbstractContextLoader - Did not detect default resource location for test class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests]: class path resource [com/contentlab/teams/java/PersonalTabSSO/PersonalTabSsoApplicationTestsContext.groovy] does not exist
18:04:18.128 [main] INFO org.springframework.test.context.support.AbstractContextLoader - Could not detect default resource locations for test class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests]: no resource found for suffixes {-context.xml, Context.groovy}.
18:04:18.128 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils - Could not detect default configuration classes for test class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests]: PersonalTabSsoApplicationTests does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
18:04:18.812 [main] DEBUG org.springframework.test.context.support.ActiveProfilesUtils - Could not find an 'annotation declaring class' for annotation type [org.springframework.test.context.ActiveProfiles] and class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests]
18:04:19.650 [main] DEBUG org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider - Identified candidate component class: file [Z:\...\PersonalTabSSO\target\classes\com\contentlab\teams\java\PersonalTabSSO\PersonalTabSsoApplication.class]
18:04:19.663 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper - Found @SpringBootConfiguration com.contentlab.teams.java.personaltabsso.PersonalTabSsoApplication for test class com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests
18:04:21.312 [main] DEBUG org.springframework.boot.test.context.SpringBootTestContextBootstrapper - @TestExecutionListeners is not present for class [com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests]: using defaults.
18:04:21.312 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper - Loaded default TestExecutionListener class names from location [META-INF/spring.factories]: [org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener, org.springframework.boot.test.mock.mockito.ResetMocksTestExecutionListener, org.springframework.boot.test.autoconfigure.restdocs.RestDocsTestExecutionListener, org.springframework.boot.test.autoconfigure.web.client.MockRestServiceServerResetTestExecutionListener, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrintOnlyOnFailureTestExecutionListener, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverTestExecutionListener, org.springframework.boot.test.autoconfigure.webservices.client.MockWebServiceServerTestExecutionListener, org.springframework.test.context.web.ServletTestExecutionListener, org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener, org.springframework.test.context.event.ApplicationEventsTestExecutionListener, org.springframework.test.context.support.DependencyInjectionTestExecutionListener, org.springframework.test.context.support.DirtiesContextTestExecutionListener, org.springframework.test.context.transaction.TransactionalTestExecutionListener, org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener, org.springframework.test.context.event.EventPublishingTestExecutionListener]
18:04:21.641 [main] DEBUG org.springframework.boot.test.context.SpringBootTestContextBootstrapper - Skipping candidate TestExecutionListener [org.springframework.test.context.transaction.TransactionalTestExecutionListener] due to a missing dependency. Specify custom listener classes or make the default listener classes and their required dependencies available. Offending class: [org/springframework/transaction/TransactionDefinition]
18:04:21.657 [main] DEBUG org.springframework.boot.test.context.SpringBootTestContextBootstrapper - Skipping candidate TestExecutionListener [org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener] due to a missing dependency. Specify custom listener classes or make the default listener classes and their required dependencies available. Offending class: [org/springframework/transaction/interceptor/TransactionAttribute]
18:04:21.657 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper - Using TestExecutionListeners: [org.springframework.test.context.web.ServletTestExecutionListener@a8e6492, org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener@1c7fd41f, org.springframework.test.context.event.ApplicationEventsTestExecutionListener@3b77a04f, org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener@7b324585, org.springframework.boot.test.autoconfigure.SpringBootDependencyInjectionTestExecutionListener@2e11485, org.springframework.test.context.support.DirtiesContextTestExecutionListener@60dce7ea, org.springframework.test.context.event.EventPublishingTestExecutionListener@662f5666, org.springframework.boot.test.mock.mockito.ResetMocksTestExecutionListener@fd8294b, org.springframework.boot.test.autoconfigure.restdocs.RestDocsTestExecutionListener@5974109, org.springframework.boot.test.autoconfigure.web.client.MockRestServiceServerResetTestExecutionListener@27305e6, org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrintOnlyOnFailureTestExecutionListener@1ef3efa8, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverTestExecutionListener@502f1f4c, org.springframework.boot.test.autoconfigure.webservices.client.MockWebServiceServerTestExecutionListener@6f8f9349]
18:04:21.782 [main] DEBUG org.springframework.test.context.support.AbstractDirtiesContextTestExecutionListener - Before test class: context [DefaultTestContext@51cd7ffc testClass = PersonalTabSsoApplicationTests, testInstance = [null], testMethod = [null], testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@30d4b288 testClass = PersonalTabSsoApplicationTests, locations = '{}', classes = '{class com.contentlab.teams.java.personaltabsso.PersonalTabSsoApplication}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@16b2bb0c, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@c7045b9, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@11f0a5a1, org.springframework.boot.test.web.reactive.server.WebTestClientContextCustomizer@732d0d24, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@13d4992d, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@2141a12, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@384ad17b], resourceBasePath = 'src/main/webapp', contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.web.ServletTestExecutionListener.activateListener' -> true]], class annotated with @DirtiesContext [false] with mode [null].
18:04:22.399 [main] DEBUG org.springframework.test.context.support.TestPropertySourceUtils - Adding inlined properties to environment: {spring.jmx.enabled=false, org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.5.5)
2021-10-23 18:04:26.007 INFO 99040 --- [ main] c.c.t.j.P.PersonalTabSsoApplicationTests : Starting PersonalTabSsoApplicationTests using Java 11.0.3 on us-jgriffith2 with PID 99040 (started by jgriffith in Z:\...\PersonalTabSSO)
2021-10-23 18:04:26.022 INFO 99040 --- [ main] c.c.t.j.P.PersonalTabSsoApplicationTests : No active profile set, falling back to default profiles: default
2021-10-23 18:04:46.624 INFO 99040 --- [ main] o.s.b.a.w.s.WelcomePageHandlerMapping : Adding welcome page template: index
2021-10-23 18:04:48.575 INFO 99040 --- [ main] c.a.s.a.aad.AADAuthenticationProperties : AzureADJwtTokenFilter Constructor.
2021-10-23 18:04:51.349 INFO 99040 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@760f1081, org.springframework.security.web.context.SecurityContextPersistenceFilter@2baac4a7, org.springframework.security.web.header.HeaderWriterFilter@e0d1dc4, org.springframework.security.web.csrf.CsrfFilter@5fe46d52, org.springframework.security.web.authentication.logout.LogoutFilter@59328218, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@25f0c5e7, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@5aea8994, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@52621501, org.springframework.security.web.session.SessionManagementFilter@3db6dd52, org.springframework.security.web.access.ExceptionTranslationFilter@1d60059f]
2021-10-23 18:04:53.236 INFO 99040 --- [ main] c.c.t.j.P.PersonalTabSsoApplicationTests : Started PersonalTabSsoApplicationTests in 30.741 seconds (JVM running for 39.552)
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 42.505 s - in com.contentlab.teams.java.PersonalTabSSO.PersonalTabSsoApplicationTests
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO]
[INFO] --- maven-jar-plugin:3.2.0:jar (default-jar) @ PersonalTabSSO ---
[INFO]
[INFO] --- spring-boot-maven-plugin:2.5.5:repackage (repackage) @ PersonalTabSSO ---
[INFO] Replacing main artifact with repackaged archive
[INFO]
[INFO] --- azure-webapp-maven-plugin:2.2.0:deploy (default-cli) @ PersonalTabSSO ---
Auth type: AZURE_CLI
Default subscription: AzureJava(a3100fd0-81b9-46d0-b470-f2318b4f452e)
Username: jeff.griffith@gmail.com
[INFO] Subscription: AzureJava(a3100fd0-81b9-46d0-b470-f2318b4f452e)
[INFO] Creating web app PersonalTabSSO-1635023142497...
[INFO] Creating app service plan asp-PersonalTabSSO-1635023142497...
[INFO] Successfully created app service plan asp-PersonalTabSSO-1635023142497.
[INFO] Successfully created Web App PersonalTabSSO-1635023142497.
[INFO] Trying to deploy external resources to PersonalTabSSO-1635023142497...
[INFO] Successfully deployed the resources to PersonalTabSSO-1635023142497
[INFO] Trying to deploy artifact to PersonalTabSSO-1635023142497...
[INFO] Deploying (Z:\...\PersonalTabSSO\target\PersonalTabSSO-0.0.1-SNAPSHOT.jar)[jar] ...
[INFO] Successfully deployed the artifact to https://personaltabsso-1635023142497.azurewebsites.net
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 03:00 min
[INFO] Finished at: 2021-10-23T18:07:08-04:00
[INFO] ------------------------------------------------------------------------
Fixing up the App Registration and application.properties File
Now that we have deployed the application, we need to go back and update the app registration in the Azure Active Directory App Registrations portal — remember that we put placeholder information there earlier. This process has four parts: capture the App Service URL, update the App Registration, update the application.properties file, and redeploy the application.
The deployment code displays the App Service URL:
[INFO] Successfully deployed the artifact to https://personaltabsso-1635023142497.azurewebsites.net
To update the URL in the App Registration, log into the Azure Portal and navigate to the App Registration you created earlier. Click the Redirect URL link.
Update the Redirect URL, ensure to check both checkboxes in the Implicit grant and hybrid flows section, then click Save.
You will also need to update the Application ID URI on the Expose an API blade, as the screenshot below shows:
Now, set the applications.properties file so that the application has this information:
# Specifies your Active Directory ID:
azure.activedirectory.tenant-id=<< Your Tenant ID >>
# Specifies your App Registration's Application ID:
azure.activedirectory.client-id=<< Your Application ID >>
# Specifies your App Registration's secret key:
azure.activedirectory.client-secret=<< Your application secret >>
azure.auth.url=/oauth2/v2.0/token
azure.instance=https://login.microsoftonline.com
azure.api=api://personaltabsso-1635023142497.azurewebsites.net/<<YourApplicationID>>
azure.scopes=https://graph.microsoft.com/User.Read
spring.thymeleaf.prefix=classpath:/templates/
You need to change the hostname in the azure.api
setting to match the URL hostname in the Azure App Service. Also, ensure to set the spring.thymeleaf.prefix
setting so that Thymeleaf can find the web pages.
Now, save the updates and redeploy the application.
Testing the Application in Teams
To run this application in Teams, I create a manifest, then import the application into Teams using this manifest. Teams provides the App Studio tool for building the manifest, but since Microsoft will deprecate this tool in 2022, I use the appPackage/manifest.json file in the Microsoft sample as a starting point to produce my manifest.json. Microsoft documents this manifest’s format on their website.
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.11/MicrosoftTeams.schema.json",
"manifestVersion": "1.11",
"version": "1.0.0",
"id": "5e443522-1974-404b-a67b-3cc939681be1",
"packageName": "com.contentlab.teams.java.personaltabsso",
"developer": {
"name": "Microsoft",
"websiteUrl": "https://www.microsoft.com",
"privacyUrl": "https://www.microsoft.com/privacy",
"termsOfUseUrl": "https://www.microsoft.com/termsofuse"
},
"name": {
"short": "Java Personal Tab SSO",
"full": "Java Personal Tab SSO"
},
"description": {
"short": "Java Personal Tab SSO",
"full": "Java Personal Tab SSO"
},
"icons": {
"color": "color.png",
"outline": "outline.png"
},
"accentColor": "#FFFFFF",
"staticTabs": [
{
"entityId": "Java Personal Tab SSO",
"name": "Java Personal Tab SSO",
"contentUrl": "https://personaltabsso-1635023142497.azurewebsites.net",
"scopes": [ "personal" ]
},
{
"entityId": "about",
"scopes": [ "personal" ]
}
],
"permissions": [ "identity", "messageTeamMembers" ],
"validDomains": [
"personaltabsso-1635023142497.azurewebsites.net",
"*.onmicrosoft.com",
"*.azurewebsites.net"
],
"webApplicationInfo": {
"id": "2ffed052-5f43-42dd-988c-0072b07b6da9",
"resource": "api://personaltabsso-1635023142497.azurewebsites.net/2ffed052-5f43-42dd-988c-0072b07b6da9"
}
}
The staticTabs
object’s scope setting is personal. This setting is essential because it identifies that this application is just for your use and that it won’t be shared. I’ll demonstrate a group application in the following article where you’ll see that these values change.
Note that the hostname from the Azure App Service URL appears in several locations in this file. Also, the value of the id
field in the webApplicationInfo
object comes from the application registration, and it’s in the azure.activedirectory.client-id
value in the application.properties file.
When the manifest.json file is ready, create the Manifest package. This package is a ZIP file containing the manifest.json file as well as the color and outline icons:
Everything is in the ZIP file’s root. Don’t include any folders in this file.
Now, we upload the manifest.zip file to Teams and add the application.
First, in Teams, click on the Apps icon on the toolbar (bottom left corner), scroll down to expose the Upload a custom app link, and click on it. Next, click the Upload for my org link. Then, select the manifest.zip file:
This action uploads the ZIP file and makes the app available in Teams.
Click on the app next, then click Add.
When you click Add, you may have to wait for the Azure App Service to spin up. This process can take several minutes and it may look like the application has failed. You can ensure that it is still working by checking the status message in the lower-left corner:
When the application is ready, you’ll see the following screen:
Next Steps
That’s it. You now have the shell of a working secured personal tab application. Since a Teams application is just a web application in an iframe, your personal tab application can do anything you want. All you have to do is replace the server-side application.
Your current Java skills and tooling are useful when building these applications. Microsoft provides Java versions of all the libraries you’ll need to interact with Teams, and the rest of the deployment is standard web application development.
When you registered this application, you created a secret. That secret is one way to create an identity for an application. The application in this tutorial uses the secret to log into Azure when it exchanges the client token for the Teams token. You can use this method to secure access to all Azure’s resources, such as databases and secret keepers. So, you can secure and access the entire application chain from any device that hosts Teams.
I’ll use this process in this series’ following two articles to reimplement the C# samples in Java. This process provides access to the entire library. Continue to the second article in this three-part series to explore creating a channel or group tab with SSO.