Amazon Alexa skill account linking using IdentityServer4

It took a lot of reading and quite some time to wade though exactly what was required to get Amazon Alexa account linking working with our Identity Server 4 oauth server. Most of the stuff out there was to perform account linking with Amazon's own OAUTH server, and not IdentityServer4.

Well, I finally got to the bottom of it all, and to save you devs valuable time and frustrations, I've laid it all out below:

  1. Create your Asp.Net Core API
  2. Configure Identity Server 4 for account linking
  3. Create your Alexa Skill
  4. Account link your Alexa skill to Identity Server 4. Amazon will take care to call your Identity Server 4 to obtain a token and manage refresh tokens for you.
  5. Call your API from Alexa.

Alexa voice command → Amazon Lambda function → [Hidden Identity Server 4 call] → Asp.Net Core API → Return Speech back to Alexa to say aloud.

Asp.Net Core API

The controller for your Alexa API should look something like this:
[Route("api/[controller]/[action]/{id}")]
[ApiController]
[Authorize(Policy = AuthSecrets.AlexaApi)]
public class AlexaController : ControllerBase
{
private readonly IDataService _dataService;
private readonly ISpeechService _speechService;
public AlexaController([NotNull] IDataService dataService, [NotNull] ISpeechService speechService)
{
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
_speechService = speechService ?? throw new ArgumentNullException(nameof(speechService));
}
// Default request if nothing specific asked for by the user
[HttpGet]
public ActionResult<string> LaunchRequest(string id)
{
return StateOfCharge(id);
}
[HttpGet("{city}")]
public ActionResult<string> CanIDriveToCity(string id, string city)
{
var result = _dataService.CanIDriveToCity(id, city);
return _speechService.CanIDriveToCity(city, result);
}
// What is the State of Charge of my car?
[HttpGet]
public ActionResult<string> StateOfCharge(string id)
{
var result = _dataService.StateOfCharge(id);
return _speechService.StateOfCharge(result);
}
}

The IDataService is used solely for accessing the database and creating a return dto class.
The ISpeechServer takes the dto class and creates speech from it. For example:
public string StateOfCharge(StateOfChargeResult result)
{
if (result.Range < 0)
return UnknownStatus;
var sb = new StringBuilder();
sb.Append($"Your car is {result.PercentCharged}% charged, with a range of {result.Range} miles, and is ");
sb.Append(result.IsCharging ? "charging. " : "not being charged. ");
return sb.ToString();
}

Notice that the Controller is protected with
[Authorize(Policy = AuthSecrets.CadAlexaApi)]
That policy is declared in the Startup.cs
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.AddXDocumentModelBinderProvider();
options.AddPascalCaseJsonProfileFormatter();
}).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddDbContext<BackOfficeContext>(options =>
{
options.UseSqlServer(Configuration.GetConnectionString("BackOffice"));
#if DEBUG
options.EnableSensitiveDataLogging();
#endif
});
services.AddAuthorization(o =>
{
o.AddPolicy( AuthSecrets.AlexaApi, policy => policy.RequireScope( AuthSecrets.AlexaApi ));
o.AddPolicy( AuthSecrets.GoogleApi, policy => policy.RequireScope( AuthSecrets.GoogleApi));
});
services
.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication(options =>
{
options.Authority = Configuration["YourCompanySetting:AuthServer"];
options.RequireHttpsMetadata = true;
});
services.AddScoped<BackOfficeDbInitializer>();
services.AddScoped<IDataService, DataService>();
services.AddSingleton<ISpeechService, SpeechService>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseRequestLogging();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseMvc();
app.UseStaticFiles();
}
}
view raw Startup.cs hosted with ❤ by GitHub

{
"YourCompanySetting": {
"AuthServer": "https://localhost:5001"
},
"ConnectionStrings": {
"BackOffice": "Server=localhost;Database=Demo;Integrated Security=true",
"Logs": "UseDevelopmentStorage=true"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Error",
"Zapinamo": "Debug"
}
}
},
"AllowedHosts": "*"
}


Configure Identity Server 4 for account linking

I've separated the identity server 4 from the API and is in a separate solution.
Nothing special in the Program.cs:
public class Program
{
public static void Main(string[] args)
{
Console.Title = "YourCompany Identity Server";
ConfigureSerilog();
var host = BuildWebHost(args);
SeedData.EnsureSeedData(host.Services);
try
{
host.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Web Host terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
}
public static IWebHost BuildWebHost(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>()
.ConfigureLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog();
})
.Build();
private static void ConfigureSerilog()
{
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? EnvironmentName.Development;
Console.WriteLine($"EnvironmentName = {environment}");
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", false, true)
.AddJsonFile($"appsettings.{environment}.json", true, true)
.AddEnvironmentVariables()
.Build();
var appName = typeof(Program).Assembly.GetName().Name;
var logsCnnStr = configuration.GetConnectionString("Logs");
var loggerConfig = new LoggerConfiguration()
.Enrich.FromLogContext()
.ReadFrom.Configuration(configuration)
.WriteTo.Console(
theme: AnsiConsoleTheme.Code,
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}"
).WriteTo.AzureTableStorage(logsCnnStr, storageTableName: typeof(Program).Namespace.Replace(".", "") + "Logs");
if (string.Equals(environment, EnvironmentName.Development, StringComparison.InvariantCultureIgnoreCase))
{
var logfile = $@"C:\Logs\{appName}\{appName}-{environment}-.txt";
Console.WriteLine($"Writing logs to {logfile}");
loggerConfig = loggerConfig
.WriteTo.Debug()
.WriteTo.File(
new CompactJsonFormatter(),
rollingInterval: RollingInterval.Day,
path: logfile
);
}
Log.Logger = loggerConfig.CreateLogger();
}
}
view raw AuthProgram.cs hosted with ❤ by GitHub

Startup.cs:
public class Startup
{
private readonly ILogger<Startup> _logger;
private readonly IConfiguration _configuration;
private readonly IHostingEnvironment _environment;
public Startup(ILogger<Startup> logger, IConfiguration configuration, IHostingEnvironment environment)
{
_logger = logger;
_configuration = configuration;
_environment = environment;
}
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => false;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
var connectionString = _configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
services.AddDefaultIdentity<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
var builder = services.AddIdentityServer(o =>
{
o.Events.RaiseErrorEvents = true;
o.Events.RaiseFailureEvents = true;
o.Events.RaiseInformationEvents = true;
o.Events.RaiseSuccessEvents = true;
})
.AddInMemoryIdentityResources(AuthConfig.GetIdentityResources())
.AddInMemoryApiResources(AuthConfig.GetApiResources())
.AddInMemoryClients(AuthConfig.GetClients(_configuration))
// This adds the operational data from DB (codes, tokens, consents)
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b => b.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly));
options.EnableTokenCleanup = true;
})
.AddAspNetIdentity<IdentityUser>();
services.AddAuthentication().AddCookie("Cookies");
if (_environment.IsDevelopment())
{
_logger.LogWarning("Using temporary developer certificate");
builder.AddDeveloperSigningCredential(false);
}
else
{
const string thumbprint = "get your own thumbprint";
X509Certificate2 cert = null;
using (var certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
certStore.Open(OpenFlags.ReadOnly);
var certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false);
// Get the first cert with the thumbprint
if (certCollection.Count > 0)
{
cert = certCollection[0];
_logger.LogInformation($"Successfully loaded cert from registry: {cert.Thumbprint}");
}
}
if (cert == null)
{
_logger.LogCritical($"Unable to load certificate {thumbprint}");
throw new Exception("Need to configure key certificate");
}
builder.AddSigningCredential(cert);
}
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseRewriter(new RewriteOptions().AddRewrite(@"^security\.txt$", ".well-known/security.txt", true));
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseIdentityServer();
app.UseMvcWithDefaultRoute();
}
}
view raw AuthStartup.cs hosted with ❤ by GitHub

AuthConfig.cs
public static class AuthConfig
{
// scopes define the resources in your system
public static IEnumerable<IdentityResource> GetIdentityResources()
{
return new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email()
};
}
public static IEnumerable<ApiResource> GetApiResources()
{
var alexaApi = new ApiResource(AuthSecrets.AlexaApi, "ALEXA API") { Description = "Access to the Alexa API" };
alexaApi.ApiSecrets.Add(new Secret(AuthSecrets.Secrets[AuthSecrets.AlexaApi].Sha256()));
var googleApi = new ApiResource(AuthSecrets.GoogleApi, "GOOGLE API") { Description = "Access to the Google API" };
googleApi.ApiSecrets.Add(new Secret(AuthSecrets.Secrets[AuthSecrets.GoogleApi].Sha256()));
return new List<ApiResource>
{
alexaApi,
googleApi
};
}
// clients want to access resources (aka scopes)
public static IEnumerable<Client> GetClients(IConfiguration configuration)
{
return new List<Client>
{
new Client
{
ClientId = AuthSecrets.Secrets[AuthSecrets.AlexaApi].ClientId,
ClientName = "Alexa",
Enabled = true,
AllowedGrantTypes = GrantTypes.Code, // Mandatory for use with Amazon Alexa
RequireConsent = true,
AllowRememberConsent = true,
AllowOfflineAccess = true, // Allow for a refresh token
RefreshTokenExpiration = TokenExpiration.Absolute,
RefreshTokenUsage = TokenUsage.ReUse,
AbsoluteRefreshTokenLifetime = 315360000, // Example: 10 years user must re-link skill
SlidingRefreshTokenLifetime = 31536000, // Example: 1 year
ClientSecrets =
{
new Secret(AuthSecrets.Secrets[AuthSecrets.AlexaApi].Sha256())
},
AllowedScopes =
{
AuthSecrets.AlexaApi, // Mandatory
IdentityServerConstants.StandardScopes.OpenId, // Mandatory
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email, // Going to pass the users email to the API
IdentityServerConstants.StandardScopes.OfflineAccess // Important!! This means Amazon will get a refresh token
},
RedirectUris =
{
// Get yours from https://eu-west-1.console.aws.amazon.com/lambda/home?region=eu-west-1
"https://layla.amazon.com/api/skill/link/REDACTED",
"https://alexa.amazon.co.jp/api/skill/link/REDACTED",
"https://pitangui.amazon.com/api/skill/link/REDACTED"
},
PostLogoutRedirectUris =
{
"https://layla.amazon.com/api/skill/link/REDACTED",
"https://alexa.amazon.co.jp/api/skill/link/REDACTED",
"https://pitangui.amazon.com/api/skill/link/REDACTED"
}
},
new Client
{
ClientId = AuthSecrets.Secrets[AuthSecrets.GoogleApi].ClientId
//etc
}
};
}
}
view raw AuthConfig.cs hosted with ❤ by GitHub

AuthSecrets.cs
public static class AuthSecrets
{
// Resources (Scopes)
public const string AlexaApi = "AlexaApi";
public const string GoogleApi = "GoogleApi";
public static Dictionary<string, AuthSecretRow> Secrets = new Dictionary<string, AuthSecretRow>
{
{ AlexaApi , new AuthSecretRow(AlexaApi, "ALEXA", "random password from https://www.grc.com/passwords.htm") },
{ GoogleApi, new AuthSecretRow(GoogleApi, "GOOGLE", "random password from https://www.grc.com/passwords.htm") },
};
}
public class AuthSecretRow
{
public string Scope { get; private set; }
public string ClientId { get; private set; }
public string Secret { get; private set; }
public AuthSecretRow(string scope, string clientId, string secret)
{
Scope = scope;
ClientId = clientId;
Secret = secret;
}
public string AuthorizationBasicHeader()
{
return $"Basic {Base64Encode($"{ClientId}:{Secret}")}";
}
public string Sha256()
{
return Sha256(Secret);
}
public static string Sha256(string input)
{
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
public static string Base64Encode(string plainText)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
return Convert.ToBase64String(plainTextBytes);
}
}
view raw AuthSecrets.cs hosted with ❤ by GitHub

Clone the IdentityServer4 samples source code from GitHub and copy the Quickstarts, Views and wwwroot folders to your identity server implementation. I previously tried other Quickstarts from other IdentityServer repos, and found this one to be the best. Your mileage may vary...

Nothing special in SeedData.cs
public static class SeedData
{
public static void EnsureSeedData(IServiceProvider serviceProvider)
{
using (var scope = serviceProvider.CreateScope())
{
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
UpdateDatabase(logger, scope);
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
EnsureUserExists(logger, userMgr, "Fred", "Bloggs",
"fred.bloggs@yourcompany.com", "Some-Random-Password-7",
"{ 'street_address': '', 'locality': 'Torquay', 'postal_code': 'TQ1 2AU', 'country': 'England' }");
//EnsureUserExists(logger, userMgr, todo);
}
}
private static void UpdateDatabase(ILogger logger, IServiceScope scope)
{
logger.LogInformation("Updating the database...");
scope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>().Database.Migrate();
//scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>().Database.Migrate(); // not required, but here for completeness
scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();
logger.LogInformation("Database updated.");
}
private static void EnsureUserExists(ILogger logger, UserManager<IdentityUser> userMgr,
string givenName, string familyName,
string email, string password, string address)
{
var user = userMgr.FindByEmailAsync(email).Result;
if (user != null)
{
logger.LogDebug($"User {email} already exists.");
return;
}
user = new IdentityUser { UserName = email, Email = email, EmailConfirmed = true };
var result = userMgr.CreateAsync(user, password).Result;
if (!result.Succeeded)
{
var error = result.Errors.First().Description;
logger.LogError(error);
throw new Exception(error);
}
result = userMgr.AddClaimsAsync(user, new[]
{
new Claim(JwtClaimTypes.Name, $"{givenName} {familyName}"),
new Claim(JwtClaimTypes.GivenName, givenName),
new Claim(JwtClaimTypes.FamilyName, familyName),
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
new Claim(JwtClaimTypes.Address, address, IdentityServer4.IdentityServerConstants.ClaimValueTypes.Json), // optional
// Have access to everything
new Claim(JwtClaimTypes.Scope, AuthSecrets.AlexaApi),
new Claim(JwtClaimTypes.Scope, AuthSecrets.GoogleApi)
}).Result;
if (!result.Succeeded)
{
var error = result.Errors.First().Description;
logger.LogError(error);
throw new Exception(error);
}
logger.LogDebug($"User {email} created");
}
}
view raw SeedData.cs hosted with ❤ by GitHub

Account link your Alexa skill to Identity Server 4

In https://developer.amazon.com/alexa/console/ask/
Click on Build, and the ACCOUNT LINKING tab on the left




Select the "Auth Code Grant" with the following options:
Authorization URI: https://url-to-your-identity-server/connect/authorize
Access Token URI: https://url-to-your-identity-server/connect/token
Client ID: ALEXA
Client Secret: take the raw unencrypted string from AuthSecrets[CadAlexaApi].Secret
Client Authentication Scheme: HTTP Basic (Recommended)
Scopes:
  • email
  • openid
  • AlexaApi
  • offline_access
Domain List: is empty
Default Access Token Expiration Time: 31536000 Not sure if you can leave this blank or not.
The Redirect URLs shown on your screen are what you need to for Client configuration above.

Call your API from Alexa

I've kept the lambda function as Node.JS.
Install the NPM package node-fetch
/* eslint-disable func-names */
/* eslint quote-props: ["error", "consistent"]*/
/* jslint node: true */
/* jshint esversion: 6 */
'use strict';
const skillName = 'your skills name';
const alexa = require('ask-sdk-core');
const fetch = require('node-fetch');
const apiEndPoint = 'https://your-company.azurewebsites.net/api/Alexa/';
const sorry = 'Sorry, I\'m having trouble getting the answer for you. Please try again later.';
const youMustAuthenticate = 'Please perform account linking to use this skill. I sent instructions in how to do this in your Alexa App.';
function standardResponse(handlerInput, speechText) {
if (speechText === youMustAuthenticate) {
return handlerInput.responseBuilder
.speak(speechText)
.withLinkAccountCard()
.getResponse();
} else {
return handlerInput.responseBuilder
.speak(speechText)
.withSimpleCard(skillName, speechText)
.getResponse();
}
}
function callApi(handlerInput, path, method, params = '') {
var accessToken = handlerInput.requestEnvelope.context.System.user.accessToken;
if (accessToken === undefined) {
return Promise.resolve(youMustAuthenticate);
} else {
return fetch('https://your-identity-server-url/connect/userinfo',
{
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken }
})
.then(authRes => authRes.text())
.then(x => JSON.parse(x))
.then(userInfo => {
//console.log('------- userinfo response --------');
//console.log(userInfo);
return fetch(apiEndPoint + path + '/' + userInfo.email + '/' + params,
{
method: method,
headers: { 'Authorization': 'Bearer ' + accessToken }
})
.then(res => res.text())
.then(speechText => {
console.log(speechText);
return speechText;
})
.catch(err => {
console.error(err);
return sorry;
});
})
.catch(err => {
console.error(err);
return sorry;
});
}
}
// The LaunchRequest event occurs when the skill is invoked without a specific intent.
const launchRequestHandler = {
canHandle(handlerInput) {
return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
},
handle(handlerInput) {
return callApi(handlerInput, 'LaunchRequest', 'GET')
.then(speechText => standardResponse(handlerInput, speechText));
}
};
const canIDriveToCityIntentHandler = {
canHandle(handlerInput) {
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'CanIDriveToCityIntent';
},
handle(handlerInput) {
var citySlot = handlerInput.requestEnvelope.request.intent.slots.city;
if (citySlot && citySlot.value) {
var city = citySlot.value;
return callApi(handlerInput, 'CanIDriveToCity', 'GET', city)
.then(speechText => standardResponse(handlerInput, speechText));
}
return standardResponse(handlerInput, 'Sorry, I don\'t know what city that is');
}
};
const stateOfChargeIntentHandler = {
canHandle(handlerInput) {
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'StateOfChargeIntent';
},
handle(handlerInput) {
return callApi(handlerInput, 'StateOfCharge', 'GET')
.then(speechText => standardResponse(handlerInput, speechText));
}
};
// etc
const helpIntentHandler = {
canHandle(handlerInput) {
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent';
},
handle(handlerInput) {
const helpReprompt = 'What can I help you with?';
const helpMessage = 'You can ask blah, or, blah blah. ' + helpReprompt;
return handlerInput.responseBuilder
.speak(helpMessage)
.reprompt(helpReprompt)
.withSimpleCard(skillName, helpMessage)
.getResponse();
}
};
const cancelAndStopIntentsHandler = {
canHandle(handlerInput) {
return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
(handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent' ||
handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent');
},
handle(handlerInput) {
const speechText = 'Goodbye!';
return standardResponse(handlerInput, speechText);
}
};
const sessionEndedRequestHandler = {
canHandle(handlerInput) {
return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
},
handle(handlerInput) {
// Any cleanup logic goes here
return handlerInput.responseBuilder.getResponse();
}
};
const errorHandler = {
canHandle() {
return true;
},
handle(handlerInput, error) {
console.log(`Error handled: ${error.message}`);
const speechText = 'Sorry, I can\'t understand the command. Please say again.';
return handlerInput.responseBuilder
.speak(speechText)
.reprompt(speechText)
.getResponse();
},
};
exports.handler = alexa.SkillBuilders.custom()
.addRequestHandlers(
launchRequestHandler,
stateOfChargeIntentHandler,
canIDriveToCityIntentHandler,
// etc
helpIntentHandler,
cancelAndStopIntentsHandler,
sessionEndedRequestHandler)
.addErrorHandlers(errorHandler)
.lambda();
view raw index.js hosted with ❤ by GitHub

Zip the folder up, and upload it to the amazon lambda associated with your skill.

Popular posts from this blog

Service Broker sys.transmission_queue clean up

Execution of user code in the .NET Framework is disabled

AWS DynamoDB vs Azure CosmosDB vs Azure Table Storage pricing comparison