A gem to handle OpenID Connect authentication via Hydra for Raspberry Pi Foundation Rails applications.
The Engine includes the Rails CSRF protection gem, so this does not need to be included in the parent application
Add this line to your application's Gemfile:
gem 'rpi_auth', git: 'https://github.com/RaspberryPiFoundation/rpi-auth.git', tag: 'v3.2.0'
And then execute:
$ bundle
Add an initializer file to configure rpi_auth e.g. in config/initializers/rpi_auth.rb
containing the following information:
RpiAuth.configure do |config|
config.auth_url = 'http://localhost:9001' # The url of Hydra being used
config.auth_token_url = nil # Normally this would be unset, defaulting to AUTH_URL above. When running locally under Docker, set to http://host.docker.internal:9001
config.auth_client_id = 'gem-dev' # The Hydra client ID
config.auth_client_secret = 'secret' # The Hydra client secret
config.brand = 'brand-name' # The brand of the application (see allowed brands in Profile application: app/middleware/brand.js)
config.host_url = 'http://localhost:3000' # The url of the host site used (needed for redirects)
config.identity_url = 'http://localhost:3002' # The url for the profile instance being used for auth
config.user_model = 'User' # The name of the user model in the host app being used, use the name as a string, not the model itself
config.scope = 'openid email profile force-consent' # The required OIDC scopes
config.session_keys_to_persist = 'foo bar' # Optional: any session keys to persist across sessions (as the session is reset upon log in)
config.success_redirect = '/' # After succesful login the route the user should be redirected to; this will override redirecting the user back to where they were when they started the log in / sign up flow (via `omniauth.origin`), so should be used rarely / with caution. This can be a string or a proc, which is executed in the context of the RpiAuth::AuthController.
config.bypass_auth = false # Should auth be bypassed and a default user logged in
end
The values above will allow you to login using the gem-dev
client seeded in Hydra provided you run the host application on port 3009
. An example configuration can be found in the dummy app.
You will need to change the values to match your application, ideally through ENV vars eg.
RpiAuth.configure do |config|
config.auth_url = ENV.fetch('AUTH_URL', nil)
config.auth_token_url = ENV.fetch('AUTH_TOKEN_URL', nil)
config.auth_client_id = ENV.fetch('AUTH_CLIENT_ID', nil)
config.auth_client_secret = ENV.fetch('AUTH_CLIENT_SECRET', nil)
config.brand = 'raspberrypi-org'
config.host_url = ENV.fetch('HOST_URL', nil)
config.identity_url = ENV.fetch('IDENTITY_URL', nil)
config.user_model = 'User'
config.scope = 'openid email profile force-consent'
config.success_redirect = ENV.fetch('OAUTH_SUCCESS_REDIRECT_URL', nil)
config.bypass_auth = ActiveModel::Type::Boolean.new.cast(ENV.fetch('BYPASS_OAUTH', false))
end
Add the CurrentUser
concern to the host application's ApplicationController:
class ApplicationController < ActionController::Base
include RpiAuth::Controllers::CurrentUser
end
This provides access to the current_user
method in controllers and helpers. The dummy app has an example of this.
Add the authenticatable
concern to the host application's User model:
class User < ApplicationRecord
include RpiAuth::Models::Authenticatable
end
This model needs to be the same one defined in the initializer, an instance will be created on login. Again, checkout the user model in the dummy app.
To login via Hydra your app needs to send the user to /auth/rpi
via a POST request:
link_to 'Log in', '/auth/rpi', method: :post
# or:
button_to 'Log in', '/auth/rpi'
A GET request will be blocked by the CSRF protection gem.
Alternatively you can use the path helpers:
link_to 'Log in', rpi_auth_login_path, method: :post
# or:
button_to 'Log in', rpi_auth_login_path
There is a helper for the sign-up buttons, which pushes the user through the sign-up flow.
button_to 'Sign up', rpi_auth_signup_path
And there is also a helper for the logout route:
link_to 'Log out', rpi_auth_logout_path
There are a three possible places the user will end up at following logging in, in the following order:
- The
success_redirect
URL or proc. - The specified
returnTo
URL. - The page the user was on (if the Referer header is sent in).
- The root path of the application.
Note that none of these places can be on a different host, i.e. they have to be inside your application.
The success_redirect
set in the RpiAuth configuration block will trump
everything, so only use this configuration option if you always want your users
to end up at the same place.
If you wish to redirect users to the next step in the process, e.g. to a
registration form, then you should supply a parameter called returnTo
which
is then used to redirect after log in/sign up are successful.
button_to 'Log in to start registration', rpi_auth_login_path, params: { returnTo: '/registration_form' }
If this parameter is missing Omniauth uses the HTTP Referer header meaning (most) users will end up back on the page where they started the auth flow (this is often the most preferable situation).
Finally, if none of these things are set, we end up back at the application root.
On occasion you may wish to heavily customise the way the login redirect is handled. For example, you may wish to redirect to something a bit more dynamic than either the static success_redirect
or original HTTP referer/returnTo
parameter.
Fear not! You can set success_redirect
to a Proc in the configuration, which will then be called in the context of the request.
config.success_redirect = -> { request.env['omniauth.origin'] + "?cache_bust=#{Time.now.to_i}&username=#{current_user.nickname}" }
will redirect the user to there /referer/url?cache_bust=1231231231
, if they started the login process from /referer/url
page. The proc can return a string or a nil. In the case of a nil, the user will be redirected to the returnTo
parameter. The return value will be checked to make sure it is local to the app. You cannot redirect to other URLs/hosts with this technique.
You can use variables and methods here that are available in the RpiAuth::AuthController, i.e. things like
current_user
-- the current logged in user.request.env['omniauth.origin']
(the originalreturnTo
value)
Beware here be dragons! 🐉 You might get difficult-to-diagnose bugs using this technique. The Proc in your configuration may be tricky to test, so keep it simple. If your Proc raises an exception, the URL returned will default to /
and there should be a warning in the Rails log saying what happened.
When using this, you will find that Rails needs to be restarted when you change the proc, as the configuration block is only evaluated on start-up.
It is also possible to send users to pages within your app when logging out. Just set the returnTo
parameter again.
link_to 'Log out', rpi_auth_logout_path, params: { returnTo: '/thanks-dude' }
This has to be a relative URL, i.e. it has to start with a slash. This is to ensure there's no open redirect.
If your app has a catch-all route at the end of the routing table, you must
shuffle Rails Engine loading order, putting RpiAuth::Engine
above the default
engines. To do this add a config.railties_order
line in the Application
class in config/application.rb
.
# If there is a globbed route in the route file, (e.g. `get /*slug, ...`)
# we need to make sure that the RpiAuth routes take precedence over that
# route, otherwise the globbed route will catch all the routes defined in
# the engine.
#
# See https://api.rubyonrails.org/classes/Rails/Engine.html#class-Rails::Engine-label-Loading+priority
config.railties_order = [RpiAuth::Engine, :main_app, :all]
There are some standardised test helpers in RpiAuth::SpecHelpers
that can be used when testing.
stub_auth_for(user:)
enables the Omniauth test mode, and makes sure any auth requests succeed, returning this user.log_in(user:)
goes through the log-in process for that user, either using Capybara-styleclick_on
methods, or POST'ing directly to the auth endpoint.
There is also a page at /rpi_auth/test
that has log-in and sign-up buttons which can be navigated to as part of the test suite to avoid having to render pages, or navigate into the shadow roots.
To user these helpers you should add this to your spec/rails_helper.rb
, inside the RSpec.configure do |config|
block.
config.include RpiAuth::SpecHelpers, type: :request
config.include RpiAuth::SpecHelpers, type: :system
Diagnosing issues with OpenID Connect can be tricky, so here are some things to try.
Typically we run both Profile/Hydra and our applications in Docker. Both the browser and the application have to communicate with Hydra, and in a docker situation this means using two different hostnames. The browser can use localhost
, but inside docker containers localhost
refers to the container itself, not the machine running Docker. So the container has to use docker.host.internal
instead. As a result, the application needs to have a separate URL to check tokens on. We configure this as the auth_token_url
.
Typical local environment variables for development are
AUTH_CLIENT_ID=my-hydra-client-dev
AUTH_CLIENT_SECRET=1234567890
AUTH_TOKEN_URL=http://host.docker.internal:9001/
AUTH_URL=http://localhost:9001 # The URL where Hydra is running
HOST_URL=http://localhost:3000 # The URL where your app is running
IDENTITY_URL=http://localhost:3002 # The URL where Profile (Pi Accounts) is running
When tokens are issued, the OpenID Connect library validates that the token's "issuer" (iss
) value. This library assumes that it matches the auth_url
value, complete with a trailing slash. If this is not the case, you can set the issuer manually. It should match the value in either the docker-compose.yml
in the profile repo, or at http://localhost:9001/.well-known/openid-configuration
when Hydra is running.
The Omniauth OpenID Connect gem can use discovery to work out the majority of the configuration. However this does not work in development, as the discovery URL is assumed to be available over HTTPS which is not the case in this scenario.
This project follows semantic versioning, so upgrades between minor and patch versions should not need any code or configuration changes.
For major version upgrades see the upgrading docs.
The gem is available as open source under the terms of the MIT License.
Run:
# Set the version of rails we want to follow
export BUNDLE_GEMFILE=gemfiles/rails_7.0.gemfile
bundle install
from the root directory. This will also install the gems required by the dummy application found at spec/dummy
.
The dummy app can be interacted with in the same way as any Rails application. Go into spec/dummy
and run commands as normal.
You will need to install the javascript dependencies with yarn install
first.
The dummy app can be started with bundle exec rails s -p 3009
and will then be viewable on localhost:3009.
This port matches that set in spec/dummy/config/initializers/rpi_auth.rb
and will allow redirects to work after logging out using a local installation of Hydra.
There is a simple page at the dummy app root that has a login link and will show a logout link once logged in via Hydra.
If running Hydra locally you will need to configure a new client with the following values:
{
id: 'gem-dev',
secret: 'secret',
redirect_urls: 'http://localhost:3009/rpi_auth/auth/callback'
}
There is a seed in the Profile repo to set this client up correctly, running the v1 setup tasks will create this client
Ensure to update lib/rpi_auth/version.rb
when publishing a new version.
$ bundle exec rspec
This Gem should work with Rails 6.1+. To test Rails 6.1, you'll want to use gemfiles/rails_6.1.gemfile
as your gemfile, and then run rspec using that.
# Set the version of rails we want to follow
export BUNDLE_GEMFILE=gemfiles/rails_6.1.gemfile
bundle install
bundle exec rspec
Ensure that the version number has been updated in: https://github.com/RaspberryPiFoundation/rpi-auth/blob/main/lib/rpi_auth/version.rb
Ensure that the changelog has been updated in: https://github.com/RaspberryPiFoundation/rpi-auth/blob/main/CHANGELOG.md
Create and push a new tag from main
:
git tag v1.2.2
git push --tags
Create a new release at: https://github.com/RaspberryPiFoundation/rpi-auth/releases/new
Select the newly created tag, the target should be main
, and the release name should be the same as the tag name eg. v1.2.2
. Enter a short description of the release (generally best to copy / paste the changelog entry).