Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ gem 'valid_email2'

# Authentication
gem 'cancancan'
gem 'identity', ref: '26f582e', github: 'quintel/identity_rails'
gem 'identity', ref: '7590604', github: 'quintel/identity_rails'

# javascript
gem 'babel-transpiler'
Expand Down
9 changes: 6 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ GIT

GIT
remote: https://github.com/quintel/identity_rails.git
revision: 26f582ec28eb865be40c3a459192759049823a43
ref: 26f582e
revision: 759060417940e07220252ad4d60bad72afcb90c1
ref: 7590604
specs:
identity (0.1.0)
dry-configurable (>= 1.0)
dry-initializer (>= 3.1)
dry-types (~> 1.7)
dry-validation (>= 1.10)
faraday (>= 2)
jwt (>= 2.5)
omniauth (>= 2.1)
omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.4)
Expand Down Expand Up @@ -306,6 +307,8 @@ GEM
json-schema (6.2.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
jwt (3.2.0)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
Expand Down Expand Up @@ -763,4 +766,4 @@ RUBY VERSION
ruby 4.0.2

BUNDLED WITH
4.0.6
4.0.10
32 changes: 27 additions & 5 deletions app/assets/javascripts/lib/app.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ class @AppView extends Backbone.View
@accessToken = null

if globals.access_token
sessionStorage.removeItem('etm-session-recovery')
@accessToken = new AccessToken(
globals.access_token.token,
new Date(globals.access_token.expires_at * 1000)
)
@configureTokenRefresh(@accessToken)
else
@accessToken = new GuestToken()
@tryRecoverSession()

@api = new ApiGateway
api_path: globals.ete_url
Expand Down Expand Up @@ -134,10 +136,6 @@ class @AppView extends Backbone.View
configureTokenRefresh: (accessToken) ->
return unless accessToken

refresh = () ->
console.log('Refreshing access token')
window.location.reload()

expiresInMS = accessToken.expiresAt.getTime() - new Date().getTime();

# A random number of seconds between 0 and 10 is added to the refresh time. This avoids multiple
Expand All @@ -150,7 +148,31 @@ class @AppView extends Backbone.View
"in #{Math.round(refreshInMS/100/60)/10} minutes (#{Math.floor(refreshInMS/1000)}s)"
)

window.setTimeout(refresh, refreshInMS)
window.setTimeout(@refreshSession, refreshInMS)

# Renews the shared session cookie via MyETM, then reloads so the server injects the fresh token.
# The cookie can only be refreshed by MyETM (not by a plain reload), so if it can no longer be
# refreshed (refresh token expired/revoked) we send the user to sign in instead of reloading on a
# dead token — which is what used to loop.
refreshSession: =>
fetch("#{idp_url}/session/refresh", { method: 'POST', credentials: 'include' })
.then (resp) => if resp.ok then window.location.reload() else @redirectToSignIn()
.catch => @redirectToSignIn()

# On arriving without a valid session cookie, attempt a single silent refresh: recovers a session
# whose short access cookie expired while its refresh cookie is still valid (the cookie-era
# replacement for the old silent-SSO probe). Guarded via sessionStorage so a genuinely signed-out
# user does not reload-loop; the guard is cleared whenever a valid session is present.
tryRecoverSession: =>
return if sessionStorage.getItem('etm-session-recovery')
sessionStorage.setItem('etm-session-recovery', '1')

fetch("#{idp_url}/session/refresh", { method: 'POST', credentials: 'include' })
.then (resp) -> window.location.reload() if resp.ok

redirectToSignIn: ->
returnTo = encodeURIComponent(window.location.href)
window.location.href = "#{idp_url}/identity/sign_in?return_to=#{returnTo}"

# Returns a deferred object on which the .done() method can be called
#
Expand Down
22 changes: 5 additions & 17 deletions app/controllers/api/api_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ module API
class APIController < ActionController::API
abstract!

rescue_from ETModel::Tokens::DecodeError do |e|
render json: { errors: [e.message] }, status: :forbidden
end
include Identity::ResourceServer

rescue_from ActionController::ParameterMissing do |e|
render json: { errors: [e.message] }, status: :bad_request
Expand All @@ -21,19 +19,19 @@ class APIController < ActionController::API

# Returns the current user, if a token is set and is valid.
def current_user
return nil unless token
return nil unless decoded_token

@current_user ||= User.from_jwt!(token) if token
@current_user ||= User.from_jwt!(decoded_token)
end

# Verifies that a token is set and is valid.
def verify_token!
token || render(json: { errors: ['Missing or invalid token'] }, status: :unauthorized)
decoded_token || render(json: { errors: ['Missing or invalid token'] }, status: :unauthorized)
end

# Verifies that the token has the desired scopes.
def verify_scopes!(required_scopes)
missing = Array(required_scopes).reject { |scope| token['scopes'].include?(scope) }
missing = Array(required_scopes).reject { |scope| decoded_token['scopes'].include?(scope) }

return if missing.empty?

Expand All @@ -42,15 +40,5 @@ def verify_scopes!(required_scopes)
status: :forbidden
)
end

# Returns the contents of the current token, if an Authorization header is set.
def token
return @token if @token
return nil if request.authorization.blank?

request.authorization.to_s.match(/\ABearer (.+)\z/) do |match|
return @token = ETModel::Tokens.decode(match[1])
end
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/api_passthru_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def passthru
response.set_header('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate')
url = URI.parse(Settings.ete_url)
url.path = "/api/v3/scenarios/#{params[:id]}/#{params[:rest]}"
url.query = { access_token: identity_access_token.token }.to_query if signed_in?
url.query = { access_token: session_access_token }.to_query if session_access_token

track_csv_download

Expand Down
30 changes: 25 additions & 5 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base

helper :all
helper_method :engine_client, :current_user, :admin?
helper_method :session_access_token, :session_access_token_expires_at

before_action :current_user
before_action :initialize_current
Expand Down Expand Up @@ -101,8 +102,8 @@ def redirect_to_back(default_url = root_path)
# Returns the Faraday client which should be used to communicate with MyETM. This contains the
# user authentication token if the user is logged in.
def my_etm_client
if current_user
identity_session.access_token.http_client
if (token = session_access_token)
Identity.http_client(access_token: token)
else
Identity.http_client
end
Expand All @@ -111,15 +112,34 @@ def my_etm_client
# Returns the Faraday client which should be used to communicate with ETEngine (resource server).
# This contains the user authentication token if the user is logged in.
def engine_client
if current_user
identity_session.access_token.http_resource_client
if (token = session_access_token)
Identity.http_client(access_token: token, resource: true)
else
Identity.http_client(resource: true)
end
end

# The bearer token for the current browser session: the shared domain JWT cookie, when present AND
# still valid (identity_token only decodes an unexpired, correctly-signed cookie). Guarding on
# identity_token means we never hand the page an expired cookie — which would make the front-end's
# refresh logic loop on a dead token. Used for server-side calls to MyETM/ETEngine and handed to
# the page so its JS can call the API.
def session_access_token
request.cookies[Identity.config.session_cookie_name].presence if identity_token
end

# Expiry (unix seconds) of the session access token, so the front-end can schedule a refresh.
def session_access_token_expires_at
identity_token&.dig('exp')
end

def current_user
@current_user ||= User.from_session_user!(identity_user) if signed_in?
@current_user ||=
if identity_token
# Shared JWT session cookie: find-or-create the local user from the verified claims, as the
# API path does, so a cookie-authenticated visitor without a local row is not bounced.
User.from_jwt!(identity_token)
end
rescue ActiveRecord::RecordNotFound
# The user has been deleted from the database. This means the user has deleted their account.
reset_session
Expand Down
4 changes: 2 additions & 2 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ def js_globals
end

json.access_token do
json.token identity_access_token.token
json.expires_at identity_access_token.expires_at
json.token session_access_token
json.expires_at session_access_token_expires_at
end
else
json.current_user nil
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/pages_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def co2_factsheet_options
scenario_id: params[:scenario],
time: @time,
non_energy: params[:non_energy],
access_token: signed_in? ? identity_access_token.token : nil
access_token: session_access_token
}
end

Expand Down
28 changes: 7 additions & 21 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,19 @@ class User < ApplicationRecord

scope :ordered, -> { order('name') }

# Performs sign-in steps for an Identity::User.
#
# If a matching user exists in the database, it will be updated with the latest data from the
# Identity::User. Otherwise, a new user will be created.
#
# Returns the user. Raises an error if the user could not be saved.
def self.from_identity!(identity_user)
where(id: identity_user.id).first_or_initialize.tap do |user|
is_new_user = !user.persisted?
user.identity_user = identity_user
user.name = identity_user.name

user.save!
end
end

# Finds or creates a user from a JWT token.
#
# The token's claims are also set as identity_user: email/roles/admin? all prefer this fresh,
# per-request identity data over the persisted columns, which are only ever set at creation, so a
# role granted/revoked at the identity provider after that first login is still reflected here.
def self.from_jwt!(token)
id = token['sub']
name = token.dig('user', 'name')

raise 'Token does not contain user information' unless id.present? && name.present?

User.find_or_create_by!(id: token['sub']) { |u| u.name = name }
end

def self.from_session_user!(identity_user)
find(identity_user.id).tap { |u| u.identity_user = identity_user }
user = User.find_or_create_by!(id: id) { |u| u.name = name }
user.identity_user = Identity::User.from_jwt_claims(token)
user
end
end
4 changes: 4 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,8 @@

# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true

# Allow the ETLauncher cross-app parent domain so the shared etm_session cookie can be scoped to
# a dotted parent the browser accepts
config.hosts << ENV.fetch('ETM_HOST_PARENT', '.local.energytransitionmodel.com')
end
48 changes: 2 additions & 46 deletions config/initializers/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,57 +32,13 @@
config.scope = 'openid profile email roles scenarios:read scenarios:write scenarios:delete'
config.validate_config = ENV['DOCKER_BUILD'] != 'true'
config.resource_uri = Settings.identity.resource_uri

# Create or update the local user when signing in.
config.on_sign_in = lambda do |session|
User.from_identity!(session.user)
end

# We've had cases where browsers make multiple simultaneous requests to the ETM; presumably a
# browser restoring pages removed from memory. If the access token has expired, this causes the
# second request to fail due to the refresh token having expired when the first refreshed the
# access token.
#
# 1. Request one starts
# 2. Request two starts
# 3. Request one refreshes the access token
# 4. Request two tries to refresh the token, but the refresh token has expired in (3)
# 5. Request one completes.
# 6. Request two fails and signs the user out.
#
# To prevent this, if refreshing the token results in an invalid grant error, we
# wait a short period and attempt to reload the session from the database.
config.on_invalid_grant = lambda do |controller, exception|
id_session_key = Identity::ControllerHelpers::IDENTITY_SESSION_KEY

sleep(1)

# rubocop:disable Rails/DynamicFindBy
db_session = controller.session.id &&
ActiveRecord::SessionStore::Session.find_by_session_id(controller.session.id.private_id)
# rubocop:enable Rails/DynamicFindBy

expires_at = db_session&.data&.dig(id_session_key, :access_token, :expires_at)
token = db_session&.data&.dig(id_session_key, :access_token, :token)

if token && expires_at && expires_at > Time.now.to_i
controller.session[id_session_key] = db_session.data[id_session_key]
Identity::Session.load(db_session.data[id_session_key])
else
controller.reset_session
Sentry.capture_exception(exception)
nil
end
end
end

if Rails.env.development?
# In development, ETEngine often runs as only a single process. Pre-fetch the JWKS keys from the
# In development, ETModel often runs as only a single process. Pre-fetch the JWKS keys from the
# engine so that the first request to the API does not deadlock.
require_relative '../../lib/etmodel/tokens'

begin
ETModel::Tokens.jwk_set
Identity::TokenDecoder.jwk_set
rescue StandardError => e
warn("Couldn't pre-fetch MyETM public key: #{e.message}")
end
Expand Down
1 change: 0 additions & 1 deletion config/shakapacker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ development:

# Reference: https://webpack.js.org/configuration/dev-server/
dev_server:
https: false
host: localhost
port: 3035
# Hot Module Replacement updates modules while the application is running without a full reload
Expand Down
Loading