I am creating a cookbook organization/meal planner app. I've been working with Ember for a while, but the backend of the app I work on professionally is mostly a black box. I call api's with the authorization process that's already in place. I wanted to set up my own graphql api using Rails. Getting Auth0 to talk to both applications has been a real head-scratcher. There are not a lot (any) tutorials that I could find that just gave you the steps to follow so that it just works.
For the past few nights working on this, I've had so many tabs open to different bits of documentation, blogs, and Stack Overflow questions that my browser has been regularly crashing. Here is what I did to pull it all together.
Setup Auth0
Setting up Auth0 was relatively painless: sign up/login, click on the create application button from the dashboard, choose Single Page Application. Unfortunately, there is no quickstart for Ember. Name the app, set the allowed callback URL: http://localhost:4200 (this is all in development mode for now) and allowed logout URL: http://localhost:4200
Once the application is created, the app's domain, client Id and client secret are available in the settings page of the application.
Next, set up the api application. Again, quite easy, just provide a name and an identifier. The identifier will be used in the applications as the API Audience key.
Configuring Ember
Create a new app:
$ ember new no-stories
Remove the ember-welcome-page.
Install the ember-simple-auth-auth0 add-on:
$ ember install ember-simple-auth-auth0
Configure auth0 add-on:
- auth0 configuration variables:
#config/auth0-variables.js
module.exports = {
clientID: "your Auth0 client id",
domain: "your Auth0 domain"
- add auth--variables to gitignore
- in environment.js
#config/environment.js
+ const AUTH_CONFIG = require('./auth0-variables')
module.exports = function(environment) {
let ENV = {
...
+ 'ember-simple-auth: {
+ authenticationRoute: 'login',
+ auth0: {
+ clientId: AUTH_CONFIG.clientID,
+ domain: AUTH_CONFIG.domain,
+ logoutReturnToURL: '/',
+ audience: 'your API Audience key',
+ enableImpersonation: false,
+ silentAuth: {}
+ }
+ },
...
- application route & controller
#routes/application.js
import Route from '@ember/routing/route'
import RSVP from 'rsvp'
import ApplicationRouteMixin from 'ember-simple-auth-auth0/mixins/application-route-mixin'
export default Route.extend(ApplicationRouteMixin, {
beforeSessionExpired() {
// Do custom async logic here, e.g. notify
// the user that they are about to be logged out.
return RSVP.resolve()
}
// Do other application route stuff here. All hooks provided by
// ember-simple-auth's ApplicationRouteMixin, e.g. sessionInvalidated(),
// are supported and work just as they do in basic ember-simple-auth.
})
#controllers/application.js
import Controller from '@ember/controller'
import { inject as service } from '@ember/service'
export default Controller.extend({
session: service(),
actions: {
login() {
const authOptions = {
responseType: 'token id_token',
scope: 'openid email profile',
audience: 'API audience key'
}
this.session.authenticate(
'authenticator:auth0-universal',
authOptions,
(err, email) => {
alert(`Email link sent to ${email}`)
}
)
},
logout() {
this.session.invalidate()
}
}
})
Next, create a simple navigation component to display login/logout button. The styles are from ember-tachyon-shim.
#app/templates/navigation.hbs
<header class="bg-black-90 fixed w-100 ph3 pv3 pv4-ns ph4-m ph5-l">
<nav class="f6 fw6 ttu tracked">
{{#if session.isAuthenticated}}
<a href="#" class="link dim white dib mr3" {{action "logout"}}>
Logout
</a>
{{else}}
<a href="#" class="link dim white dib mr3" {{action "login"}}>
Login
</a>
{{/if}}
<a class="link dim white dib mr3" href="#" title="Home">
Placeholder
</a>
<a class="link dim white dib" href="#" title="Contact">
Contact
</a>
</nav>
</header>
#app/components/navigation.js
import Component from '@ember/component'
import { inject as service } from '@ember/service'
export default Component.extend({
session: service(),
actions: {
login() {
this.login()
},
logout() {
this.logout()
}
}
})
Plug the navigation component into the application template:
#app/templates/application.hbs
<Navigation @login={{action "login"}} @logout={{action "logout"}} />
<div class="main">
{{outlet}}
</div>
By this point, the application can authenticate through Auth0 by clicking the login button, and be able to log this.session.data.authenticated, which should contain a lot of information, particularly two json web tokens: accessToken and idToken.
Setup the Rails api
Setting up the rails app was relatively straightforward. I was able to follow Auth0's rails documentation with only a few tweaks because I'm using Rails 6. Also, the rack-cors gem needs to be configured, which is not addressed at all in the Auth0 documentation that I saw. Here are the steps:
$ rails new my-api --api
Adding the Auth0 config values to credentials.yml.enc
:
$ EDITOR="code --wait" rails credentials:edit
will open a tab in VS Code to the decrypted credentials file
# Auth0
auth0:
clientID: auth0 client id
domain: auth0 domain
secret: auth0 secret
audience: api identifier
# lib/json_web_token.rb
# frozen_string_literal: true
require 'net/http'
require 'uri'
class JsonWebToken
def self.verify(token)
JWT.decode(token, nil,
true, # Verify the signature of this token
algorithm: 'RS256',
iss: 'https://YOUR_DOMAIN/',
verify_iss: true,
aud: Rails.application.secrets.auth0_api_audience,
verify_aud: true) do |header|
jwks_hash[header['kid']]
end
end
def self.jwks_hash
jwks_raw = Net::HTTP.get URI("https://YOUR_DOMAIN/.well-known/jwks.json")
jwks_keys = Array(JSON.parse(jwks_raw)['keys'])
Hash[
jwks_keys
.map do |k|
[
k['kid'],
OpenSSL::X509::Certificate.new(
Base64.decode64(k['x5c'].first)
).public_key
]
end
]
end
end
In my version, I have changed the jwks_raw assignment from a straight request to a cache, to cut down on the number of requests sent to the auth0 server:
def self.jwks_hash
- jwks_raw - Net::HTTP.get URI("https//YOUR_DOMAIN/.well-known/jwks.json")
+ jwks_raw = Rails.cache.fetch("JWKS_HASH", exires_in: 10.hours) do
+ Net::HTTP.get URI("https://#{Rails.application.credentials[:auth0][:domain]}.well-known/jwks.json")
+ end
jwks_keys = Array(JSON.parse(jwks_raw)['keys'])
...
Doing this requires updating config/environments/development.rb
to store items in memory:
#config/environments/development.rb
...
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp', 'caching-dev.txt').exist?
config.cache_store = :memory_store
config.public_file_server.headers = {
'Cache-Control' => "public, max-age=#{2.days.to_i}"
}
else
config.action_controller.perform_caching = false
- config.cache_store = :null_store
+ config.cache_store = :memory_store
end
...
Next I define a Secured concern:
# app/controllers/concerns/secured.rb
# frozen_string_literal: true
module Secured
extend ActiveSupport::Concern
included do
before_action :authenticate_request!
end
private
def authenticate_request!
auth_token
rescue JWT::VerificationError, JWT::DecodeError
render json: { errors: ['Not Authenticated'] }, status: :unauthorized
end
def http_token
if request.headers['Authorization'].present?
request.headers['Authorization'].split(' ').last
end
end
def auth_token
JsonWebToken.verify(http_token)
end
end
The next section of the Auth0 documentation is about validating scopes. I included this because I intend to use it eventually, but for this stage of the project, I'm only concerned with the /private
route, with no scope associated.
SCOPES = {
'/private' => nil,
'/private-scoped' => ['read:messages']
}
private
def authenticate_request!
@auth_payload, @auth_header = auth_token
render json: { errors: ['Insufficient scope'] }, status: :unauthorized unless scope_included
rescue JWT::VerificationError, JWT::DecodeError
render json: { errors: ['Not Authenticated'] }, status: :unauthorized
end
def scope_included
if SCOPES[request.env['PATH_INFO']] == nil
true
else
# The intersection of the scopes included in the given JWT and the ones in the SCOPES hash needed to access
# the PATH_INFO, should contain at least one element
(String(@auth_payload['scope']).split(' ') & (SCOPES[request.env['PATH_INFO']])).any?
end
end
To smoke test that it is actually working as intended, I add a /private
route to the app/config/routes.rb
#app/config/routes.rb
Rails.application.routes.draw do
+ get "/private", to: "private#private"
...
And create a controller:
# app/controllers/private_controller.rb
# frozen_string_literal: true
class PrivateController < ActionController::API
include Secured
def private
render json: 'Hello from a private endpoint! You need to be authenticated to see this.'
end
end
Lastly, the rack-cors gem need to be configured to allow requests from the ember app:
In the gemfile, uncomment the rack-cors gem and run bundle install
. Then in app/config/application.rb
:
...
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*', :headers => :any, :methods => [:get, :post, :options]
end
end
The origins
is overly-permissive at this point, and I'll want to tighten it up later, but for now I'm only concerned with getting it up and running.
The moment of truth
In the Ember app, I generate a smoke test route:
$ ember g route private-test
And import the ember-fetch
add-on:
$ ember install ember-fetch
I set up my test in the app/routes/private-test.js
file:
import Route from '@ember/routing/route'
import ApplicationRouteMixin from 'ember-simple-auth-auth0/mixins/application-route-mixin'
import { inject as service } from '@ember/service'
import fetch from 'fetch'
export default Route.extend(ApplicationRouteMixin, {
session: service(),
model() {
return fetch('http://localhost:3000/private', {
method: 'GET',
cache: false,
headers: {
Authorization: `Bearer ${this.session.data.authenticated.accessToken}`,
'Access-Control-Allow-Origin': '*'
}
}).then(response => {
console.log(response)
})
}
})
With everything in place, start both servers and the flow should look like this:
-
localhost:4200/
- click the "Login" button - Redirected to the Auth0 login page
- Enter credentials
- Returned to
localhost:4200/
- Navigate to
localhost:4200/private-test
- In the developer tools, the api response will be logged out.
The response isn't very pretty, and you need to have the network tab open to actually see the "Hello from a private endpoint!" string, but the authentication is working, and the ember and rails applications can talk to each other through Auth0.
My eventual goals for this application is to set the api up as a graphql api. There are a lot of things that can be better organized in this proof-of-concept code, such as the headers should probably be added somewhere besides the individual routes. When I finally got the authenticated response, I felt I needed to write it down asap before I forgot everything I did.
Top comments (1)
It seems there is a typo:
expires_in
inRails.cache.fetch("JWKS_HASH", exires_in: 10.hours)
instead ofexires_in
.