By Sam Morgan, Head of Education at Makers Academy
Editor's note: This is part six and the final blog of our Makers Academy series for Ruby developers. Learn more about this free training on the Alexa Skills Kit and read part one, part two, part three, part four, and part five. Check out the full training course for free online.
We will be using the Ralyxa framework during this module.
In earlier modules, we built an intermediate skill called Pizza Buddy. This skill allows users to:
In this module, we will authenticate users via Open Authentication (OAuth) using Login with Amazon. Users will not be able to order a pizza until they are logged into their account, and users will only be able to list pizzas that they themselves have ordered.
This kind of authentication is designed to work seamlessly with other user management protocols you might use in your application. We will be creating a User
entity from scratch, but authentication in this way can hook into Devise, Clearance, Sorcery, or any other authentication framework you might use.
A completed version of the Pizza Buddy application is available here. You can use the commits to guide your build, or fork and play with the completed application. This walkthrough covers commits 16 - 20.
You can fork directly from this commit to start this module.
You can take two routes through this walkthrough:
If you get stuck, you can refer to the detailed walkthrough as the former directly maps to the latter.
LaunchRequest
, authenticate and log in, or sign up, the user. Checkpoint 1ConfirmPizzaOrder
intent handler to send an account linking card to the user if they lack an OAuth access token.ConfirmPizzaOrder
intent handler to ensure that the saved pizza belongs_to
the authenticated user.ListOrders
intent handler to list only pizzas belonging to the currently-authenticated user.
Before we can authenticate our users, we must enable account linking on our skill. This requires several pieces of information. To get these piece of information, we must set up a Login with Amazon (LWA) Security Profile.
If you would prefer to use another OAuth service to authenticate your users, substitute the LWA setup steps with those of setting up your alternative provider.
To create a new LWA profile for your Alexa skill, log in to the Amazon Developer Console. Then:
Create a new security profile. Use the table below to fill out the required sections of the form.
Key | Value |
---|---|
Name | alexa-pizza-buddy |
Description | Pizza Buddy |
Privacy Notice URL | https://www.amazon.com/gp/help/customer/display.html?nodeId=468496 (use your own when launching non-test skills) |
Icon URL | http://assets.makersacademy.com/images/alexa-ruby-course/6/pizza.jpg (or another pizza!) |
Make a note of your Client ID and Client Secret:
Copy a redirect URL from the "Account Linking"' section of your skill Configuration. It may start with "https://layla.amazon.com/api/skill" or with "https://pitangui.amazon.com/api/skill". Paste this redirect URL into the Security Profile "Allow Return URLs" option (it's under "Web Settings"):
Return to the Account Linking section of our Pizza Buddy skill, and use the table below to fill out the required sections of the form.
Key | Value |
---|---|
Account Linking | Yes |
Authorization URL | https://www.amazon.com/ap/oa |
Client ID | The Client ID from the LWA Security Profile. This has a format such as amzn1-application-oa2-client-xxx |
Scope | LWA supports several scopes. For this example, let’s use “profile”. This will allow Pizza Buddy to retrieve a full name for the user, and greet them personally |
Authorization Grant Type | Select 'Auth Code Grant' |
Access Token URI | https://api.amazon.com/auth/o2/token |
Client Secret | The Client Secret from the LWA Security Profile |
Client Authentication Scheme | HTTP Basic (Recommended) |
Once you save this data, you are ready to send users an account linking card in the event they are not yet authenticated with your skill.
Now that we have configured account linking, let's update our LaunchRequest
intent handler to send the user an account linking card if they lack an access token. The user's workflow will be:
In the first instance (1), our Sinatra application receives a JSON packet with no user access token:
// redacted for brevity
{
"session": {
"user": {
"userId": "<REDACTED>"
}
}
}
Once the user has completed the Login with Amazon authentication step, their subsequent requests (4) include an access token:
// redacted for brevity
{
"session": {
"user": {
"userId": "<REDACTED>",
"accessToken": "Atza|<SOME LONG STRING>"
}
}
}
We can use the existence of this access token to tell if the user has authenticated with LWA yet. Ralyxa provides us with some convenience methods for reading it, and for sending the account linking card if the user has not yet authenticated:
# inside intents/launch_request.rb
intent "LaunchRequest" do
return tell("Please authenticate Pizza Buddy via the Alexa app.", card: link_account_card) unless request.user_access_token_exists?
ask("Welcome to Pizza Buddy. Would you like a new pizza, or to list orders?")
end
You may need to update to the latest version of Ralyxa for the above syntax (a guard clause) to work correctly.
Using an Alexa device, test this card. On launch, the user should receive a card in their Alexa app. Clicking on that will take them to Login with Amazon. After providing their details, the user explicitly authorizes Pizza Buddy to access their name and email address:
Once they agree, they are redirected to a page that reports success:
LaunchRequest
, authenticate and log in, or sign up, the userWouldn't it be great to welcome the user more personally to Pizza Buddy? Once the user has logged in with OAuth, we can access permitted Amazon customer data from the Amazon API. While we're doing this, we will create, or find, a corresponding User
entity in our database.
Our ideal interface for the LaunchRequest
intent handler would be something along these lines:
# inside intents/launch_request.rb
intent "LaunchRequest" do
return tell("Please authenticate Pizza Buddy via the Alexa app.", card: link_account_card) unless request.user_access_token_exists?
user = User.authenticate(request.user_access_token)
ask("Welcome to Pizza Buddy, #{ user.name }. Would you like a new pizza, or to list orders?")
end
Let's set up a new User
model that can save some key attributes – name
and access_token
, to start – to the database. We'll test-drive this implementation:
# in spec/user_spec.rb
require 'user'
RSpec.describe User do
before do
DataMapper.setup(:default, 'postgres://pizzabuddy@localhost/pizzabuddytest')
DataMapper.finalize
User.auto_migrate!
end
describe 'Saving to a database' do
it 'starts out unpersisted' do
user = User.new
expect(user.id).to be_nil
end
it 'can be persisted' do
user = User.new(name: "Timmy", access_token: "AccessToken")
user.save
persisted_user = User.last
expect(persisted_user.id).not_to be_nil
expect(persisted_user.name).to eq "Timmy"
expect(persisted_user.access_token).to eq "AccessToken"
end
end
end
Run rspec
. A simple way to solve the failure is to implement a basic User
class inside /lib
:
# in lib/user.rb
require 'data_mapper'
class User
include DataMapper::Resource
property :id, Serial
property :name, String
property :access_token, Text
end
We're using the
Text
type for theaccess_token
property as access tokens can be longer than theString
type would allow.
We will also need to update database.rb
to account for the new tables:
# in database.rb
require 'data_mapper'
require './lib/pizza'
require './lib/user'
DataMapper.setup(:default, 'postgres://pizzabuddy@localhost/pizzabuddy')
DataMapper.finalize
Pizza.auto_migrate!
User.auto_migrate!
Now that we have a basic user entity, serializable to a database, let's implement an .authenticate
method on the User
class. We expect this method to either find an existing user, or create a new user, depending on their name and access token:
# in spec/user_spec.rb, with some omissions for brevity
require 'user'
RSpec.describe User do
# ...DataMapper setup...
# ...pre-existing Database tests...
describe '.authenticate' do
let(:amazon_response) do
amazon_response = {
name: "Timmy Tales"
}.to_json
end
let(:client) { double(:"Net::HTTP", get: amazon_response) }
it 'creates a user if one does not exist' do
expect { User.authenticate("AccessToken", client) }.to change { User.count }.by(1)
end
it 'retrieves a user if a one with that name and access token does exist' do
User.create(name: "Timmy", access_token: "AccessToken")
expect { User.authenticate("AccessToken", client) }.not_to change { User.count }
expect(User.authenticate("AccessToken", client).name).to eq "Timmy"
expect(User.authenticate("AccessToken", client).access_token).to eq "AccessToken"
end
end
end
Let's implement this method in User
:
# in lib/user.rb
require 'data_mapper'
require 'net/http'
require 'uri'
require 'json'
class User
AMAZON_API_URL = "https://api.amazon.com/user/profile".freeze
include DataMapper::Resource
property :id, Serial
property :name, String
property :access_token, String
def self.authenticate(access_token, client = Net::HTTP)
uri = URI.parse("#{ AMAZON_API_URL }?access_token=#{ access_token }")
first_name = JSON.parse(client.get(uri))["profile"]["name"].split(" ").first
first_or_create(name: first_name, access_token: access_token)
end
end
Returning to our LaunchRequest
handler:
# inside intents/launch_request.rb
intent "LaunchRequest" do
return tell("Please authenticate Pizza Buddy via the Alexa app.", card: link_account_card) unless request.user_access_token_exists?
user = User.authenticate(request.user_access_token)
ask("Welcome to Pizza Buddy, #{ user.name }. Would you like a new pizza, or to list orders?")
end
Once the user has completed the account linking card, our application will authenticate, then retrieve or save a user.
Double-check that everything has been correctly set up. Test this step using your Alexa device, by saying "Alexa, launch Pizza Buddy" and checking your Alexa app.
ConfirmPizzaOrder
intent handler to send an account linking card to the user if the user lacks an OAuth access token.You can jump directly to this step by forking from this commit.
Just as with the LaunchRequest
handler, we need users to be authenticated before they confirm their order. Add an authentication guard clause to the first line of the ConfirmPizzaOrder
intent handler:
# in intents/confirm_pizza_order.rb
require './lib/pizza'
require 'active_support/core_ext/array/conversions'
intent "ConfirmPizzaOrder" do
return tell("To confirm your order, please authenticate Pizza Buddy via the Alexa app.", card: link_account_card) unless request.user_access_token_exists?
pizza = Pizza.new(size: request.session_attribute('size'), toppings: request.session_attribute('toppings'))
pizza.save
response_text = ["Thanks! Your #{ pizza.size } pizza with #{ pizza.toppings.to_sentence } is on ",
"its way to you. Your order ID is #{ pizza.id }. Thank you for using Pizza Buddy!"].join
card_title = "Your Pizza Order ##{ pizza.id }"
card_body = "You ordered a #{ pizza.size } pizza with #{ pizza.toppings.to_sentence }!"
card_image = "https://image.ibb.co/jeRZLv/alexa_pizza.png"
pizza_card = card(card_title, card_body, card_image)
tell(response_text, card: pizza_card)
end
ConfirmPizzaOrder
intent handler to ensure that the saved pizza belongs_to
the authenticated user.Users should own any pizzas they have ordered. Implement a has_many
relationship between User
and Pizza
:
# in lib/user.rb, with omissions for brevity
require_relative './pizza'
class User
include DataMapper::Resource
property :id, Serial
property :name, String
property :access_token, String
# implement a one-to-many relationship
has n, :pizzas
# ...rest of class...
end
Also, implement the reflective relationship, so Pizza
instances can belong_to
a User
:
# in lib/pizza.rb, with omissions for brevity
require_relative './user'
class Pizza
include DataMapper::Resource
property :id, Serial
property :size, String
property :toppings, PgArray
# implement a belongs_to relationship
belongs_to :user
# ...rest of class...
end
We will need to update the spec/pizza_spec.rb
tests for Pizza to reflect this new requirement:
# in spec/pizza_spec.rb, with omissions for brevity
require 'pizza'
RSpec.describe Pizza do
# ... rest of tests ...
describe 'Saving to a database' do
it 'starts out unpersisted' do
pizza = Pizza.new(size: 'small', toppings: ['cheese', 'ham'])
expect(pizza.id).to be_nil
end
it 'can be persisted' do
# Add a user_id parameter here, to mock the required user this pizza must belong_to
pizza = Pizza.new(size: 'small', toppings: ['cheese', 'ham'], user_id: 1)
pizza.save
expect(pizza.id).not_to be_nil
end
end
end
Modify the ConfirmPizzaOrder
intent, so created pizzas automatically belong to an individual user:
require './lib/pizza'
require 'active_support/core_ext/array/conversions'
intent "ConfirmPizzaOrder" do
return tell("To confirm your order, please authenticate Pizza Buddy via the Alexa app.", card: link_account_card) unless request.user_access_token_exists?
user = User.authenticate(request.user_access_token)
# any new pizzas must belong to the authenticated user
pizza = user.pizzas.new(size: request.session_attribute('size'), toppings: request.session_attribute('toppings'))
pizza.save
response_text = ["Thanks! Your #{ pizza.size } pizza with #{ pizza.toppings.to_sentence } is on ",
"its way to you. Your order ID is #{ pizza.id }. Thank you for using Pizza Buddy!"].join
card_title = "Your Pizza Order ##{ pizza.id }"
card_body = "You ordered a #{ pizza.size } pizza with #{ pizza.toppings.to_sentence }!"
card_image = "https://image.ibb.co/jeRZLv/alexa_pizza.png"
pizza_card = card(card_title, card_body, card_image)
tell(response_text, card: pizza_card)
end
Ordered Pizza
s now belong to the authenticated User
who ordered them.
ListOrders
intent handler to list only pizzas belonging to the currently-authenticated user.Unauthenticated users should not be able to list any orders. Authenticated users should only be able to list orders that they have made.
Implement an authentication guard clause in the ListOrders
intent handler to block unauthenticated users:
# in intents/list_orders.rb
require './lib/pizza'
intent "ListOrders" do
return tell("To list your orders, please authenticate Pizza Buddy via the Alexa app.", card: link_account_card) unless request.user_access_token_exists?
orders = Pizza.first(4).map { |order| "a #{ order.size } pizza with #{ order.toppings.to_sentence }" }
response_text = ["There are #{ user.pizzas.count } orders. ",
"#{ orders.to_sentence }. ",
"You can ask to list orders again, or order a pizza."].join
ask(response_text)
end
To ensure that users can only list orders that they have made, authenticate users with an access token, and scope the query to pizzas belonging to the authenticated user:
require './lib/pizza'
intent "ListOrders" do
return tell("To list your orders, please authenticate Pizza Buddy via the Alexa app.", card: link_account_card) unless request.user_access_token_exists?
# Authenticate users with an access token
user = User.authenticate(request.user_access_token)
# Scope the pizza query to pizzas belonging to the authenticated user
if user.pizzas.any?
orders = user.pizzas.first(4).map { |order| "a #{ order.size } pizza with #{ order.toppings.to_sentence }" }
response_text = ["You have made #{ user.pizzas.count } orders. ",
"#{ orders.to_sentence }. ",
"You can ask to list orders again, or order a pizza."].join
else
response_text = "You haven't made any orders yet. To start, say 'order a pizza'."
end
ask(response_text)
end
Congratulations! You now know how to authenticate skill users with OAuth. This concludes module 6. You can fork the completed application at the end of this module from this commit.
We have now covered all aspects required to build an advanced skill using Alexa and Ruby. Need a refresher? Check out all previous modules here. Now, go build your own Alexa skill! We can’t wait to see what build for Alexa.
The Alexa Skills Kit (ASK) enables developers to build capabilities, called skills, for Alexa. ASK is a collection of self-service APIs, documentation, tools, and code samples that make it fast and easy for anyone to add skills to Alexa.
Developers have built more than 13,000 skills with ASK. Explore the stories behind some of these innovations, then start building your own skill. Once you publish your skill, apply to receive a free Echo Dot. This promotion is available in the US only. Check out our promotions in the UK, Germany, and India.