Skip to content

Commit

Permalink
Setup basic Jobseeker OneLogin sign-in flow
Browse files Browse the repository at this point in the history
We are setting up the basic end-to-end flow for a jobseeker to sign-in
into our service through GovUK One Login.

- Builds a OneLogin auth request to be redirected to from the
  Jobseeker's "Sign in" button in our TV front page.
- Creates a controller to receive the response from OneLogin for our
  auth requests, parse the response values and either:
  A) Redirect the user to the root page with an error message if
     the parsed response is an error or invalid.
  B) If a successfull response is sent, it attempts to retrieve the
     associated user info from OneLogin by getting a One Login token and
     using it to retrieve the user info endpoint. All of it using our
     OneLogin client.
- Attempts to insulate the OneLogin business/validation logic from the
  controller, leaving the controller for orchestrating the service
  retrieving OneLogin info, setting session values, setting the user and
  redirecting.
- This work is just an anemic basic flow to do and end-to-end sign-in
  through OneLogin. We need to build our full user flows from here.
  • Loading branch information
scruti committed Aug 22, 2024
1 parent 095d44f commit fdf4f62
Show file tree
Hide file tree
Showing 16 changed files with 645 additions and 37 deletions.
37 changes: 37 additions & 0 deletions app/controllers/jobseekers/govuk_one_login_callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class Jobseekers::GovukOneLoginCallbacksController < Devise::OmniauthCallbacksController
include Jobseekers::GovukOneLogin::Errors
# Devise redirects response from Govuk One Login to this method.
# The request parameters contain the response from Govuk One Login from the user authentication through their portal.
def openid_connect
if (govuk_one_login_user = Jobseekers::GovukOneLogin::UserFromAuthResponse.call(params, session))
session[:govuk_one_login_id_token] = govuk_one_login_user.id_token

jobseeker = Jobseeker.find_or_create_from_govuk_one_login(email: govuk_one_login_user.email,
govuk_one_login_id: govuk_one_login_user.id)

session.delete(:govuk_one_login_state)
session.delete(:govuk_one_login_nonce)
sign_in_and_redirect jobseeker if jobseeker
else
error_redirect
end
rescue GovukOneLoginError => e
Rails.logger.error(e.message)
error_redirect
end

private

def error_redirect
return if jobseeker_signed_in?

flash[:alert] = "There was a problem signing in. Please try again."
redirect_to root_path
end

# Devise method to redirect the user after sign in.
# We need to build our own logic to redirect the user to the correct pages.
def after_sign_in_path_for(_resource)
jobseekers_job_applications_path
end
end
20 changes: 20 additions & 0 deletions app/helpers/jobseekers_login_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module JobseekersLoginHelper
include Jobseekers::GovukOneLogin::Helper

def jobseeker_login_uri
params = generate_login_params
session[:govuk_one_login_state] = params[:state]
session[:govuk_one_login_nonce] = params[:nonce]

govuk_one_login_uri(:login, params)
end

def jobseeker_logout_uri
params = generate_logout_params(session[:govuk_one_login_id_token])
govuk_one_login_uri(:logout, params)
end

def jobseeker_login_button(class: "")
govuk_button_link_to(t("buttons.sign_in"), jobseeker_login_uri.to_s, class:)
end
end
17 changes: 17 additions & 0 deletions app/models/jobseeker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,21 @@ def account_closed?
def needs_email_confirmation?
!confirmed? || unconfirmed_email.present?
end

def self.find_or_create_from_govuk_one_login(email:, govuk_one_login_id:)
return unless email.present? && govuk_one_login_id.present?

if (user = find_by("LOWER(email) = ?", email.downcase))
user.update(govuk_one_login_id: govuk_one_login_id) if user.govuk_one_login_id != govuk_one_login_id
user
else
# OneLogin users won't need/use this password. But is required by validations for in-house Devise users.
# Eventually when all the users become OneLogin users, we should be able to remove the password requirement.
random_password = Devise.friendly_token
create!(email: email.downcase,
govuk_one_login_id: govuk_one_login_id,
password: random_password,
confirmed_at: Time.zone.now)
end
end
end
7 changes: 5 additions & 2 deletions app/services/jobseekers/govuk_one_login.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
module Jobseekers::GovukOneLogin
BASE_URL = Rails.application.config.govuk_one_login_base_url
CALLBACKS_BASE_URL = "#{ENV.fetch('DOMAIN').include?('localhost') ? 'http' : 'https'}://#{ENV.fetch('DOMAIN')}".freeze

CALLBACKS = {
login: "https://#{ENV.fetch('DOMAIN')}/jobseekers/auth/openid_connect/callback",
logout: "https://#{ENV.fetch('DOMAIN')}/jobseekers/sign_out",
login: "#{CALLBACKS_BASE_URL}/jobseekers/auth/openid_connect/callback",
logout: "#{CALLBACKS_BASE_URL}/jobseekers/sign_out",
}.freeze

ENDPOINTS = {
Expand All @@ -13,4 +14,6 @@ module Jobseekers::GovukOneLogin
user_info: "#{BASE_URL}/userinfo",
jwks: "#{BASE_URL}/.well-known/jwks.json",
}.freeze

User = Struct.new(:id, :email, :id_token, keyword_init: true)
end
7 changes: 3 additions & 4 deletions app/services/jobseekers/govuk_one_login/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# see https://docs.sign-in.service.gov.uk/
class Jobseekers::GovukOneLogin::Client
include Jobseekers::GovukOneLogin
include Jobseekers::GovukOneLogin::Errors

JWT_SIGNING_ALGORITHM = "RS256".freeze

Expand All @@ -27,8 +28,7 @@ def tokens
response = http.request(request)
JSON.parse(response.body)
rescue StandardError => e
Rails.logger.error "GovukOneLogin.tokens: #{e.message}"
{}
raise ClientRequestError.new("GovukOneLogin.tokens", e.message)
end

# GET /userinfo
Expand All @@ -38,8 +38,7 @@ def user_info(access_token)
response = http.request(request)
JSON.parse(response.body)
rescue StandardError => e
Rails.logger.error "GovukOneLogin.user_info: #{e.message}"
{}
raise ClientRequestError.new("GovukOneLogin.user_info", e.message)
end

def decode_id_token(token)
Expand Down
14 changes: 14 additions & 0 deletions app/services/jobseekers/govuk_one_login/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Jobseekers::GovukOneLogin::Errors
class GovukOneLoginError < StandardError
def initialize(error = "GovukOneLogin", description = "Failed to authenticate with Govuk One Login")
super("#{error}: #{description}")
end
end

class ClientRequestError < GovukOneLoginError; end
class AuthenticationError < GovukOneLoginError; end
class SessionKeyError < GovukOneLoginError; end
class TokensError < GovukOneLoginError; end
class IdTokenError < GovukOneLoginError; end
class UserInfoError < GovukOneLoginError; end
end
28 changes: 28 additions & 0 deletions app/services/jobseekers/govuk_one_login/helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module Jobseekers::GovukOneLogin::Helper
include Jobseekers::GovukOneLogin

def generate_login_params
{
redirect_uri: CALLBACKS[:login],
client_id: Rails.application.config.govuk_one_login_client_id,
response_type: "code",
scope: "email openid",
nonce: SecureRandom.alphanumeric(25),
state: SecureRandom.uuid,
}
end

def generate_logout_params(session_id_token)
{
post_logout_redirect_uri: CALLBACKS[:logout],
id_token_hint: session_id_token,
state: SecureRandom.uuid,
}
end

def govuk_one_login_uri(endpoint, params)
URI.parse(ENDPOINTS[endpoint]).tap do |uri|
uri.query = URI.encode_www_form(params)
end
end
end
70 changes: 70 additions & 0 deletions app/services/jobseekers/govuk_one_login/user_from_auth_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
class Jobseekers::GovukOneLogin::UserFromAuthResponse
include Jobseekers::GovukOneLogin::Errors

attr_reader :auth_response, :session

def initialize(auth_response, session)
@auth_response = auth_response
@session = session
end

def call
validate_user_session
validate_auth_response

client = Jobseekers::GovukOneLogin::Client.new(auth_response["code"])

tokens_response = client.tokens
validate_tokens_response(tokens_response)

id_token = client.decode_id_token(tokens_response["id_token"])[0]
validate_id_token(id_token)

user_id = id_token["sub"]
user_info_response = client.user_info(tokens_response["access_token"])
validate_user_info(user_info_response, user_id)

Jobseekers::GovukOneLogin::User.new(id: user_id,
email: user_info_response["email"],
id_token: tokens_response["id_token"])
end

def self.call(auth_response, session)
new(auth_response, session).call
end

private

def validate_auth_response
raise AuthenticationError.new(auth_response["error"], auth_response["error_description"]) if auth_response["error"].present?
raise AuthenticationError.new("Missing", "'code' is missing") if auth_response["code"].blank?
raise AuthenticationError.new("Missing", "'state' is missing") if auth_response["state"].blank?
raise AuthenticationError.new("Invalid", "'state' doesn't match the user session 'state' value") if auth_response["state"] != session[:govuk_one_login_state]
end

def validate_user_session
raise SessionKeyError.new("Missing key", "'govuk_one_login_state' is not set in the user session") if session[:govuk_one_login_state].blank?
raise SessionKeyError.new("Missing key", "'govuk_one_login_nonce' is not set in the user session") if session[:govuk_one_login_nonce].blank?
end

def validate_tokens_response(response)
raise TokensError.new("Missing", "The tokens response is empty") if response.blank?
raise TokensError.new(response["error"], response["error_description"]) if response["error"].present?
raise TokensError.new("Missing", "'access_token' is missing") if response["access_token"].blank?
raise TokensError.new("Missing", "'id_token' is missing") if response["id_token"].blank?
end

def validate_id_token(id_token)
raise IdTokenError.new("Missing", "The id token is empty") if id_token.blank?
raise IdTokenError.new("Invalid", "'nonce' doesn't match the user session 'nonce' value") if id_token["nonce"] != session[:govuk_one_login_nonce]
raise IdTokenError.new("Invalid", "'iss' doesn't match the value configured in our service") if id_token["iss"] != "#{Rails.application.config.govuk_one_login_base_url}/"
raise IdTokenError.new("Invalid", "'aud' doesn't match our client id") if id_token["aud"] != Rails.application.config.govuk_one_login_client_id
end

def validate_user_info(user_info, govuk_one_login_id)
raise UserInfoError.new("Missing", "The user info is empty") if user_info.blank?
raise UserInfoError.new(user_info["error"], user_info["error_description"]) if user_info["error"].present?
raise UserInfoError.new("Missing", "'email' is missing") if user_info["email"].blank?
raise UserInfoError.new("Invalid", "'sub' doesn't match the user id") if user_info["sub"] != govuk_one_login_id
end
end
3 changes: 2 additions & 1 deletion app/views/home/index.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
.govuk-body class="govuk-!-margin-bottom-3" = govuk_link_to t("buttons.view_account"), jobseeker_root_path, class: "govuk-link--no-visited-state"
- else
p.govuk-body = t(".jobseeker_section.signed_out.description_html")
= govuk_button_link_to(t("buttons.sign_in"), new_jobseeker_session_path, class: "govuk-!-margin-bottom-2")
/ = govuk_button_link_to(t("buttons.sign_in"), new_jobseeker_session_path, class: "govuk-!-margin-bottom-2")
= jobseeker_login_button(class: "govuk-!-margin-bottom-2")
p.govuk-body = t(".jobseeker_section.signed_out.create_account_html", create_account_link: govuk_link_to(t(".jobseeker_section.signed_out.link_text.create_account"), new_jobseeker_registration_path, class: "govuk-link--no-visited-state"))

.govuk-grid-column-one-quarter
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
registrations: "jobseekers/registrations",
sessions: "jobseekers/sessions",
unlocks: "jobseekers/unlocks",
omniauth_callbacks: "jobseekers/govuk_one_login_callbacks",
}, path_names: {
sign_in: "sign-in",
}
Expand Down
85 changes: 85 additions & 0 deletions spec/models/jobseeker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,89 @@
end
end
end

describe ".find_or_create_from_govuk_one_login" do
let(:email) { "[email protected]" }
let(:govuk_one_login_id) { "urn:fdc:gov.uk:2022:VtcZjnU4Sif2oyJZola3OkN0e3Jeku1cIMN38rFlhU4" }

subject(:find_or_create) do
described_class.find_or_create_from_govuk_one_login(email: email, govuk_one_login_id: govuk_one_login_id)
end

RSpec.shared_examples "invalid input" do
it "returns nil" do
expect(find_or_create).to be_nil
end

it "does not create a new jobseeker" do
expect { find_or_create }.not_to change(described_class, :count)
end
end

RSpec.shared_examples "existing jobseeker" do
let!(:jobseeker) { create(:jobseeker, email: existing_jobseeker_email, govuk_one_login_id:) }

it "returns the existing jobseeker" do
expect(find_or_create).to eq jobseeker
end

it "does not create a new jobseeker" do
expect { find_or_create }.not_to change(described_class, :count)
end

context "when the existing jobseeker has no govuk one login id" do
let!(:jobseeker) { create(:jobseeker, email:, govuk_one_login_id: nil) }

it "updates the existing jobseeker with the govuk one login id" do
expect { find_or_create }.to change { jobseeker.reload.govuk_one_login_id }.from(nil).to(govuk_one_login_id)
end
end

context "when the existing jobseeker had a different govuk one login id" do
let!(:jobseeker) { create(:jobseeker, email:, govuk_one_login_id: "old_govuk_one_login_id") }

it "updates the existing jobseeker govuk one login id" do
expect { find_or_create }.to change { jobseeker.reload.govuk_one_login_id }
.from("old_govuk_one_login_id")
.to(govuk_one_login_id)
end
end
end

context "when no user email is provided" do
let(:email) { "" }

include_examples "invalid input"
end

context "when no govuk_one_login_id is provided" do
let(:govuk_one_login_id) { "" }

include_examples "invalid input"
end

context "when a jobseeker with the exact email address already exists" do
include_examples "existing jobseeker" do
let(:existing_jobseeker_email) { email }
end
end

context "when a jobseeker with the same email address with different capitalisation exist" do
include_examples "existing jobseeker" do
let(:existing_jobseeker_email) { email.upcase }
end
end

context "without an existing jobseeker with the same email address" do
it "creates a new jobseeker" do
expect { find_or_create }.to change(described_class, :count).by(1)
end

it "returns the new jobseeker with the one login id and email" do
jobseeker = find_or_create
expect(jobseeker).to be_a(described_class)
expect(jobseeker).to have_attributes(email:, govuk_one_login_id:)
end
end
end
end
Loading

0 comments on commit fdf4f62

Please sign in to comment.