SPA application authenticating to custom API using Azure AD
There’s a ton of Azure samples detailing how to call a Graph API but very little about calling an API outside of Azure e.g. on-premises.
Most of the applications are .NET MVC or .NET Core. There’s not a lot related to SPA applications.
The SPA sample was derived from the sample you get using the Identity Platform. I used the SPA sample running on IIS not the node.js one.
The API sample was generated from the standard MVC API template in Visual Studio 2019.
We use “App registration” in Azure AD. After configuring the SPA application, we get:
The name of the application is “SPA”.
For the SPA application, in index.html, we have:
var msalConfig = {
auth: {
clientId: "d6d...b39",
authority: "https://login.microsoftonline.com/00d5...6c79",
redirectURI: "http://localhost:30662/"
},
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: true
}
};
var graphConfig = {
//graphMeEndpoint: "https://graph.microsoft.com/v1.0/me"
graphMeEndpoint: "https://localhost:44323/api/Values"
};
// create a request object for login or token request calls
// In scenarios with incremental consent, the request object can be further customized
var requestObj = {
//scopes: ["user.read"]
scopes: ["api://00000003-0000-0000-c000-00000/abc"]
};
Note that the “clientID” matches the Azure AD “Application (client) ID” and the tenant ID in the “authority” matches the Azure AD “Directory (tenant) ID”.
The “graphMeEndpoint” matches the URL of the API running on my PC.
I also added a random scope of “api://00000003–0000–0000-c000–00000/abc”. This was the scope in the sample.
In practice, “abc” would be something more meaningful e.g. “Access.Write”.
The redirectURI is “http://localhost:30662/" which is where the SPA sample runs.
For “Authentication”:
Note that SPA uses the implicit grant flow and I want to get an access token and an ID token.
For “Expose an API”:
For “API permissions”:
Note that you should create the API as below before doing this so that the API app registration appears in the drop down.
For the API, I called it “SPA_API”.
In the web.config:
<appSettings>
<add key="ida:Tenant" value="azureid.onmicrosoft.com" />
<!--<add key="ida:Audience" value="https://azureid.onmicrosoft.com/APIPoC" />-->
<add key="ida:Audience" value="api://00000003-0000-0000-c000-00000" />
<add key="ida:ClientID" value="044d...c3a9" />
<add key="ida:Password" value="TQHG...PEM=" />
</appSettings>
<system.web>
<compilation debug="true" targetFramework="4.7.2" />
<httpRuntime targetFramework="4.7.2" />
</system.web>
<system.webServer>
<handlers>
<remove name="ExtensionlessUrlHandler-Integrated-4.0" />
<remove name="OPTIONSVerbHandler" />
<add name="OPTIONSVerbHandler" path="*" verb="OPTIONS" type="System.Web.DefaultHttpHandler" />
<remove name="TRACEVerbHandler" />
<add name="ExtensionlessUrlHandler-Integrated-4.0" path="*." verb="*" type="System.Web.Handlers.TransferRequestHandler" preCondition="integratedMode,runtimeVersionv4.0" />
</handlers>
</system.webServer>
Notice the “Audience”.
I added the “OPTIONS” verb handler for CORS.
In “WebApiConfig.cs”:
public static void Register(HttpConfiguration config)
{
// Web API configuration and services
//config.EnableCors(new EnableCorsAttribute("http://localhost:30662/", headers: "*", methods: "*"));
config.EnableCors(new EnableCorsAttribute("*", headers: "*", methods: "*"));
IdentityModelEventSource.ShowPII = true;
// Web API routes
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
}
I added “EnableCorsAttribute” for CORS support and “IdentityModelEventSource.ShowPII” so that the debug shows all the personal information e.g. name.
The app registration is:
The API is exposed as:
where the “Authorized client applications” id is the id of the SPA application.
For “API permissions”:
For “Authentication”:
The redirectURI is “http://localhost:44323/" which is where the SPA_API sample runs.
Tips
In “ValuesController.cs”:
[Authorize]
public class ValuesController : ApiController
{
Comment out the [Authorize] to start with so you can get everything working and then put it back. This separates out the 401 issues from general connection / mis-configuration issues.
When you remove the comment, you will then probably get the API returning:
HTTP/1.1 401 Unauthorized
I strongly suggest turning on logging in the web config:
<system.diagnostics>
<trace autoflush="true" />
<sources>
<source name="Microsoft.Owin">
<listeners>
<add name="KatanaListener" />
</listeners>
</source>
</sources>
<sharedListeners>
<add name="KatanaListener" type="System.Diagnostics.TextWriterTraceListener" initializeData="katana.trace.log" traceOutputOptions="ProcessId, DateTime" />
</sharedListeners>
<switches>
<add name="Microsoft.Owin" value="Verbose" />
</switches>
</system.diagnostics>
This will log to a file called “katana.trace.log” and gives you all kinds of useful information as to why the call to the API failed.
e.g.
Microsoft.Owin.Security.OAuth.OAuthBearerAuthenticationMiddleware Error: 0 : Authentication failed
Microsoft.IdentityModel.Tokens.SecurityTokenInvalidAudienceException: IDX10214: Audience validation failed. Audiences: 'api://00000003-0000-0000-c000-00000'. Did not match: validationParameters.ValidAudience: 'api://00000003-0000-0000-c000-000000000000' or validationParameters.ValidAudiences: 'null'.
End of tips!
OK — let’s run it.
Running the API:
Running the SPA:
Sign in and success:
Traffic
Looking on the wire, the call to the authorize endpoint uses:
GET
response_type: id_token
scope: api://00000003-0000-0000-c000-00000/abc openid profile client_id: d6d1...3b39
redirect_uri: http://localhost:30662/index.html
state: cf78...fa2e
nonce: fb4f...88f1
client_info: 1
x-client-SKU: MSAL.JS
x-client-Ver: 1.0.0
client-request-id: fc6b...c884 response_mode: fragment
Then:
GET http://localhost:30662/index.html#id_token=eyJ0eX...
where the id_token is:
“typ”: “JWT”,
“alg”: “RS256”,
“kid”: “Hl...DE” }.{
“aud”: “d6d1...3b39”,
“iss”: “https://login.microsoftonline.com/00d5...6c79/v2.0",
“iat”: 1582748339,
“nbf”: 1582748339,
“exp”: 1582752239,
“aio”: “ATQAy/8OA...lSOU8”,
“name”: “Rory ...”,
“nonce”: “fb4f...88f1”,
“oid”: “215...d6bd”,
“preferred_username”: “rory...@tenant.onmicrosoft.com”,
“sub”: “5nkx...1h_U”,
“tid”: “00d5...6c79”,
“uti”: “Vk4s...FRAA”,
“ver”: “2.0” }.
[Signature]
You can use jwt.ms or jwt.io to display the JWT.
Then:
GET http://localhost:30662/index.html#access_token=eyJ0eXAiOiJK...
where the access_token is:
"typ": "JWT",
"alg": "RS256",
"x5t": "HlC0..._tDE",
"kid": "HlC0..._tDE" }.{
"aud": "api://00000003-0000-0000-c000-00000",
"iss": "https://sts.windows.net/00d5...6c79/",
"iat": 1582748340,
"nbf": 1582748340,
"exp": 1582752240,
"acr": "1",
"aio": "ATQA...jhpBu",
"amr": [ "pwd" ],
"appid": "d6d1...3b39",
"appidacr": "0",
"ipaddr": "w.x.y.z",
"name": "Rory...",
"oid": "215e...d6bd",
"scp": "abc",
"sub": "5nkx...1h_U",
"tid": "00d5...6c79",
"unique_name": "rory...@tenant.onmicrosoft.com",
"upn": "rory...@tenant.onmicrosoft.com",
"uti": "3tL...dBAQ",
"ver": "1.0" }.
[Signature]
Then the call to the API:
GET https://localhost:44323/api/Values HTTP/1.1
And this invokes the OPTIONS call:
OPTIONS https://localhost:44323/api/Values HTTP/1.1
This is why you need to add the OPTIONS and CORS support described above.
Aside
When I first tried this, I got:
"IDX10511: Signature validation failed.
I followed this stackoverflow link to resolve this. Essentially, a scope of “User.Read” seems to cause some problems in that sample?
All good!