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:
Alexa voice command → Amazon Lambda function → [Hidden Identity Server 4 call] → Asp.Net Core API → Return Speech back to Alexa to say aloud.
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:
Notice that the Controller is protected with
[Authorize(Policy = AuthSecrets.CadAlexaApi)]
That policy is declared in the Startup.cs
Nothing special in the Program.cs:
Startup.cs:
AuthConfig.cs
AuthSecrets.cs
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
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:
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.
Install the NPM package node-fetch
Zip the folder up, and upload it to the amazon lambda associated with your skill.
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:
- Create your Asp.Net Core API
- Configure Identity Server 4 for account linking
- Create your Alexa Skill
- 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.
- 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:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[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:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
Startup.cs:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
AuthConfig.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
}; | |
} | |
} |
AuthSecrets.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | |
} | |
} |
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:
- openid
- AlexaApi
- offline_access
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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(); |
Zip the folder up, and upload it to the amazon lambda associated with your skill.