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 @@ -78,7 +78,7 @@ gem 'fever', ref: '2afebd1', github: 'quintel/fever'
gem 'refinery', ref: '36b8e34', github: 'quintel/refinery'
gem 'rubel', ref: '9fe7010', github: 'quintel/rubel'
gem 'osmosis', ref: '16fac7c', github: 'quintel/osmosis'
gem 'identity', ref: '26f582e', github: 'quintel/identity_rails'
gem 'identity', ref: '7590604', github: 'quintel/identity_rails'
gem 'turbine-graph', '>=0.1', require: 'turbine'

# system gems
Expand Down
9 changes: 6 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,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 @@ -351,6 +352,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 @@ -814,4 +817,4 @@ RUBY VERSION
ruby 4.0.2p0

BUNDLED WITH
4.0.6
4.0.10
29 changes: 4 additions & 25 deletions app/controllers/api/v3/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Api
module V3
class BaseController < ActionController::API
include ActionController::MimeResponds
include Identity::ResourceServer

rescue_from ActionController::ParameterMissing do |e|
render json: { errors: [e.message] }, status: :bad_request
Expand All @@ -27,10 +28,6 @@ class BaseController < ActionController::API
end
end

rescue_from ETEngine::TokenDecoder::DecodeError, JSON::JWT::Exception do
render json: { errors: ['Invalid or expired token'] }, status: :unauthorized
end

def set_current_scenario
@scenario = if params[:scenario_id]
Scenario.find(params[:scenario_id])
Expand All @@ -47,35 +44,17 @@ def process_action(*args)

private

# 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? && access_token_from_query.blank?

@token = if request.authorization
request.authorization.to_s.match(/\ABearer (.+)\z/) do |match|
ETEngine::TokenDecoder.decode(match[1])
end
else
ETEngine::TokenDecoder.decode(access_token_from_query)
end
end

def access_token_from_query
params.permit(:access_token)[:access_token]
end

# 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

def current_ability
@current_ability ||=
if current_user
TokenAbility.new(token, current_user)
TokenAbility.new(decoded_token, current_user)
else
GuestAbility.new
end
Expand Down
7 changes: 6 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ def initialize_memory_cache
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, the same
# way 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
reset_session
redirect_to root_path
Expand Down
18 changes: 18 additions & 0 deletions app/javascript/controllers/session_keeper_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus";
import { startSessionKeeper } from "identity/session_keeper";

// Connects to data-controller="session-keeper" on <body>. Mounted unconditionally (not gated on a
// logged-in user): the session-keeper's whole job is to recover a session whose access cookie lapsed,
// a state in which the server sees no current_user. The shared logic guards against guest reload
// loops, so an unconditional mount is safe. See identity/session_keeper in the identity gem.
export default class extends Controller {
static values = { idpUrl: String };

connect() {
this.teardown = startSessionKeeper({ idpUrl: this.idpUrlValue });
}

disconnect() {
this.teardown?.();
}
}
34 changes: 13 additions & 21 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,11 @@ def admin?
identity_user&.admin? || admin
end

# 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|
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: admin?/email/roles 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']
admin = token.dig('user', 'admin')
Expand All @@ -70,7 +59,13 @@ def self.from_jwt!(token)

raise 'Token does not contain user information' if id.blank? || name.blank? || email.blank?

User.find_or_create_by!(id: token['sub']) do |u|
user = find_or_create_from_jwt(id:, admin:, name:, email:)
user&.identity_user = Identity::User.from_jwt_claims(token)
user
end

def self.find_or_create_from_jwt(id:, admin:, name:, email:)
User.find_or_create_by!(id: id) do |u|
u.admin = admin.presence || false
u.name = name
u.user_email = email
Expand All @@ -83,10 +78,7 @@ def self.from_jwt!(token)
# id.
# Also rescue from Deadlock: https://github.com/rails/rails/issues/54281
rescue ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout
User.find_by(id: token['sub'])
end

def self.from_session_user!(identity_user)
find(identity_user.id).tap { |u| u.identity_user = identity_user }
User.find_by(id: id)
end
private_class_method :find_or_create_from_jwt
end
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
= favicon_link_tag asset_path("favicon.svg")
= javascript_importmap_tags 'inspect'

%body#data
%body#data{ data: identity_session_keeper_attributes }
.navbar.navbar-inverse
.navbar-inner
.container
Expand Down
5 changes: 5 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
# Enable hostname for puma-dev
config.hosts << 'etengine.test'

# Allow the ETLauncher cross-app parent domain so the shared etm_session cookie can be scoped to
# a dotted parent the browser accepts (a Domain cookie on .localhost is rejected). The parent is
# supplied by ETLauncher via ETM_HOST_PARENT (single source of truth); defaults to the local dev domain.
config.hosts << ENV.fetch('ETM_HOST_PARENT', '.local.energytransitionmodel.com')

# Always use a memory store so that we don't reload datasets on every request.
config.cache_store = :memory_store, { size: 512 * (1024**3) } # 512 Mb
# config.cache_store = :dalli_store
Expand Down
1 change: 1 addition & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Pin npm packages by running ./bin/importmap

pin 'identity', preload: true
pin 'identity/session_keeper' # shared session keep-alive/recovery, shipped by the identity gem
pin 'inspect', preload: true
pin '@hotwired/turbo-rails', to: 'turbo.min.js', preload: true
pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
Expand Down
19 changes: 19 additions & 0 deletions config/initializers/cors.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
# Same-registrable-domain ETM apps (ETModel, Collections) call the API from the browser carrying the
# shared session cookie, so they need credentialed CORS. The CORS spec forbids credentials with a
# wildcard origin, hence a specific-origin block, matched first. Defaults cover every prod and dev
# ETM subdomain; override with CORS_SESSION_ORIGINS (comma-separated) if needed.
SESSION_CORS_ORIGINS =
ENV["CORS_SESSION_ORIGINS"].to_s.split(",").map(&:strip).presence || [
%r{\Ahttps?://([a-z0-9-]+\.)*energytransitionmodel\.com(:\d+)?\z},
%r{\Ahttps?://([a-z0-9-]+\.)*etm\.test(:\d+)?\z}
]

Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins(*SESSION_CORS_ORIGINS)
resource '/api/*',
headers: :any,
credentials: true,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end

# Token/PAT API clients authenticate with a bearer header (no cookies), so any origin is allowed.
allow do
origins '*'
resource '/api/*',
Expand Down
46 changes: 1 addition & 45 deletions config/initializers/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,57 +34,13 @@
config.validate_config = ENV['DOCKER_BUILD'] != 'true'
# No resource app configured - ETModel is no longer a resource
config.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
# engine so that the first request to the API does not deadlock.
require_relative '../../lib/etengine/token_decoder'

begin
ETEngine::TokenDecoder.jwk
Identity::TokenDecoder.jwk_set
rescue StandardError => e
warn("Couldn't pre-fetch MyETM public key: #{e.message}")
end
Expand Down
58 changes: 0 additions & 58 deletions lib/etengine/token_decoder.rb

This file was deleted.

Loading