Skip to content
Merged
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: 'd88af33', github: 'quintel/identity_rails'
gem 'turbine-graph', '>=0.1', require: 'turbine'

# system gems
Expand Down
75 changes: 6 additions & 69 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,16 @@ GIT

GIT
remote: https://github.com/quintel/identity_rails.git
revision: 26f582ec28eb865be40c3a459192759049823a43
ref: 26f582e
revision: d88af3330404aac6fee7e3b027ec3096ec2bba8f
ref: d88af33
specs:
identity (0.1.0)
dry-configurable (>= 1.0)
dry-initializer (>= 3.1)
dry-types (~> 1.7)
dry-validation (>= 1.10)
faraday (>= 2)
omniauth (>= 2.1)
omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.4)
jwt (>= 2.5)
rails (>= 7.0.0)

GIT
Expand Down Expand Up @@ -163,9 +161,7 @@ GEM
uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
aes_key_wrap (1.1.0)
ast (2.4.3)
attr_required (1.0.2)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
Expand All @@ -176,7 +172,6 @@ GEM
rack (>= 0.9.0)
rouge (>= 1.0.0)
bigdecimal (4.1.2)
bindata (2.5.1)
binding_of_caller (2.0.0)
debug_inspector (>= 1.2.0)
bootsnap (1.23.0)
Expand Down Expand Up @@ -266,8 +261,6 @@ GEM
dry-initializer (~> 3.2)
dry-schema (~> 1.14)
zeitwerk (~> 2.6)
email_validator (2.2.4)
activemodel
equalizer (0.0.11)
erb (6.0.4)
erb-formatter (0.7.3)
Expand All @@ -285,8 +278,6 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.4)
net-http (~> 0.5)
ffi (1.17.3)
Expand Down Expand Up @@ -326,8 +317,6 @@ GEM
temple (>= 0.8.2)
thor
tilt
hashie (5.1.0)
logger
highline (3.1.2)
reline
http-accept (1.7.0)
Expand Down Expand Up @@ -358,16 +347,11 @@ GEM
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.7)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
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 @@ -455,30 +439,6 @@ GEM
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
numo-narray (0.9.2.1)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.1)
activemodel
attr_required (>= 1.0.0)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_url
webfinger (~> 2.0)
opentelemetry-api (1.11.0)
logger
opentelemetry-common (0.25.1)
Expand Down Expand Up @@ -539,17 +499,6 @@ GEM
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-oauth2 (2.3.0)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
Expand Down Expand Up @@ -706,11 +655,6 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
syntax_tree (6.3.0)
prettier_print (>= 1.2.0)
tailwindcss-rails (3.3.2)
Expand Down Expand Up @@ -742,9 +686,6 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
view_component (4.10.0)
actionview (>= 7.1.0)
activesupport (>= 7.1.0)
Expand All @@ -759,10 +700,6 @@ GEM
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0)
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
websocket (1.2.11)
websocket-driver (0.8.0)
base64
Expand Down Expand Up @@ -864,4 +801,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
3 changes: 2 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ def initialize_memory_cache
end

def current_user
@current_user ||= User.from_session_user!(identity_user) if signed_in?
@current_user ||=
(User.from_jwt!(identity_token) if identity_token)
rescue ActiveRecord::RecordNotFound
reset_session
redirect_to root_path
Expand Down
26 changes: 26 additions & 0 deletions app/javascript/controllers/session_keeper_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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 {
// expCookie names the hint cookie the keeper times off; suffixed on deployments that share a
// cookie domain, so it comes from the server (Identity::ApplicationHelper) rather than assumed.
static values = {
idpUrl: String,
expCookie: { type: String, default: "etm_session_exp" },
};

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

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 @@ -23,6 +23,11 @@
# a dotted parent the browser accepts
config.hosts << ENV.fetch('ETM_HOST_PARENT', '.local.energytransitionmodel.com')

# 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
3 changes: 3 additions & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

# 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
25 changes: 23 additions & 2 deletions config/initializers/cors.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
Rails.application.config.middleware.insert_before 0, Rack::Cors do
# frozen_string_literal: true

# 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.
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: %i[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/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
methods: %i[get post put patch delete options head]
end
end
Loading