From 74f053afdaaf92e64c232626e3173988cddbd80b Mon Sep 17 00:00:00 2001 From: louispt1 Date: Mon, 6 Jul 2026 11:16:01 +0200 Subject: [PATCH 1/7] Adopt shared JWT session cookie for authentication, retire per-app OAuth session --- Gemfile | 2 +- Gemfile.lock | 9 ++-- app/controllers/api/v3/base_controller.rb | 29 ++--------- app/controllers/application_controller.rb | 7 ++- app/models/user.rb | 34 +++++------- config/environments/development.rb | 5 ++ config/initializers/cors.rb | 19 +++++++ config/initializers/identity.rb | 46 +--------------- lib/etengine/token_decoder.rb | 58 --------------------- spec/lib/etengine/token_decoder_spec.rb | 56 -------------------- spec/models/api/token_ability_spec.rb | 2 +- spec/requests/api/v3/cookie_session_spec.rb | 29 +++++++++++ spec/requests/api/v3/update_input_spec.rb | 4 +- spec/support/authorization_helper.rb | 12 ++--- 14 files changed, 91 insertions(+), 221 deletions(-) delete mode 100644 lib/etengine/token_decoder.rb delete mode 100644 spec/lib/etengine/token_decoder_spec.rb create mode 100644 spec/requests/api/v3/cookie_session_spec.rb diff --git a/Gemfile b/Gemfile index a830f153a..3e242fd62 100644 --- a/Gemfile +++ b/Gemfile @@ -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: 'ec2dfc6', github: 'quintel/identity_rails' gem 'turbine-graph', '>=0.1', require: 'turbine' # system gems diff --git a/Gemfile.lock b/Gemfile.lock index dfe2deaa4..bcd291d66 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,8 +21,8 @@ GIT GIT remote: https://github.com/quintel/identity_rails.git - revision: 26f582ec28eb865be40c3a459192759049823a43 - ref: 26f582e + revision: ec2dfc6f2e6c6584fdfc2c3673bfb3bfb487d0aa + ref: ec2dfc6 specs: identity (0.1.0) dry-configurable (>= 1.0) @@ -30,6 +30,7 @@ GIT 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) @@ -368,6 +369,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) @@ -864,4 +867,4 @@ RUBY VERSION ruby 4.0.2p0 BUNDLED WITH - 4.0.6 + 4.0.10 diff --git a/app/controllers/api/v3/base_controller.rb b/app/controllers/api/v3/base_controller.rb index fc1a7b1e0..50870cec7 100644 --- a/app/controllers/api/v3/base_controller.rb +++ b/app/controllers/api/v3/base_controller.rb @@ -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 @@ -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]) @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 53bbb6167..8fe918028 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index f181299da..904e58ff3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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') @@ -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 @@ -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 diff --git a/config/environments/development.rb b/config/environments/development.rb index f92b04bb4..f0cd77db7 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 31e0c9f4b..8141c1c28 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -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/*', diff --git a/config/initializers/identity.rb b/config/initializers/identity.rb index 8cf51f5a0..85f5a0d87 100644 --- a/config/initializers/identity.rb +++ b/config/initializers/identity.rb @@ -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 diff --git a/lib/etengine/token_decoder.rb b/lib/etengine/token_decoder.rb deleted file mode 100644 index 51d384070..000000000 --- a/lib/etengine/token_decoder.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module ETEngine - # Handles JWT decoding, verification, and fetching. - module TokenDecoder - module_function - - DecodeError = Class.new(StandardError) - - # Decodes and verifies a JWT. - def decode(token) - decoded = JSON::JWT.decode(strip_etm_prefix(token), jwk) - - unless decoded[:iss] == Settings.identity.issuer && - decoded[:aud].include?(Settings.identity.client_uri) && - decoded[:sub].present? && - decoded[:exp] > Time.now.to_i - raise DecodeError, 'JWT verification failed' - end - - decoded - end - - # Fetches and caches the JWK from the IdP. - def jwk - jwk_cache.fetch('jwk_hash', expires_in: 24.hours) do - client = Faraday.new(jwks_uri) do |conn| - conn.request(:json) - conn.response(:json) - conn.response(:raise_error) - end - - JSON::JWK.new(client.get.body['keys'].first.symbolize_keys) - end - end - - # Fetches and caches the JWKS URI from the discovery config to avoid deadlocks. - def jwks_uri - jwk_cache.fetch('jwks_uri', expires_in: 24.hours) do - Identity.discovery_config.jwks_uri - end - end - - # Handles caching of JWKs. - def jwk_cache - @jwk_cache ||= - if Rails.env.development? - ActiveSupport::Cache::MemoryStore.new - else - Rails.cache - end - end - - def strip_etm_prefix(token) - token.sub(/^etm_(beta_)?/, '') - end - end -end diff --git a/spec/lib/etengine/token_decoder_spec.rb b/spec/lib/etengine/token_decoder_spec.rb deleted file mode 100644 index 2de0a114f..000000000 --- a/spec/lib/etengine/token_decoder_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -require 'spec_helper' - -RSpec.describe ETEngine::TokenDecoder do - let(:test_token) { JSON.parse(File.read(Rails.root.join('spec/fixtures/identity/token/idp_token.json')))['token'] } - let(:mock_jwk_set) do - { - keys: [ - { - kty: 'RSA', - kid: 'test-key-id', - use: 'sig', - n: 'test-modulus', - e: 'AQAB' - } - ] - } - end - let(:mock_decoded_token) do - { - iss: Settings.identity.api_url, - aud: 'all_clients', - sub: 1, - exp: 1730367768, # Static expiration - scopes: %w[read write] - }.with_indifferent_access - end - - before do - # Stub the Faraday client to return the mock JWK set - allow(Faraday).to receive(:new).and_return( - double('Faraday::Connection').tap do |connection| - allow(connection).to receive(:get).and_return( - double('Faraday::Response', body: mock_jwk_set.to_json) - ) - end - ) - - # Mock the jwk_set method to avoid relying on external data - allow(described_class).to receive(:jwk_set).and_return(JSON::JWK::Set.new(mock_jwk_set)) - - # Mock the decode method with static token decoding - allow(ETEngine::TokenDecoder).to receive(:decode).with(test_token).and_return(mock_decoded_token) - end - - describe '.decode' do - it 'successfully decodes a valid token' do - decoded_token = ETEngine::TokenDecoder.decode(test_token) - - # Everything is mocked basically - expect(decoded_token[:iss]).to eq(Settings.identity.api_url) - expect(decoded_token[:aud]).to eq('all_clients') - expect(decoded_token[:sub]).to be_present - expect(decoded_token[:exp]).to eq(1730367768) # Static expiration - end - end -end diff --git a/spec/models/api/token_ability_spec.rb b/spec/models/api/token_ability_spec.rb index dc36ab785..fd4b346e5 100644 --- a/spec/models/api/token_ability_spec.rb +++ b/spec/models/api/token_ability_spec.rb @@ -45,7 +45,7 @@ allow(described_class).to receive(:jwk_set).and_return(JSON::JWK::Set.new(mock_jwk_set)) # Mock the TokenDecoder behavior - allow(ETEngine::TokenDecoder).to receive(:decode).with(test_token).and_return(mock_decoded_token) + allow(Identity::TokenDecoder).to receive(:decode).with(test_token).and_return(mock_decoded_token) end let(:ability) { described_class.new(mock_decoded_token, user) } diff --git a/spec/requests/api/v3/cookie_session_spec.rb b/spec/requests/api/v3/cookie_session_spec.rb new file mode 100644 index 000000000..77daaf27e --- /dev/null +++ b/spec/requests/api/v3/cookie_session_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# The shared domain JWT cookie is auto-sent to the API on same-site requests. ResourceServer reads it +# as a bearer source, so a browser request authenticates through the same path as an API bearer. +describe 'API authentication via the shared session cookie' do + before do + NastyCache.instance.expire! + Etsource::Base.loader('spec/fixtures/etsource') + end + + let(:user) { create(:user) } + let!(:owned) { create(:scenario, user: user, private: true, created_at: 1.minute.ago) } + let(:jwt) { generate_jwt(user, scopes: match_scopes(:read)) } + + it 'authenticates a request carrying the JWT in the etm_session cookie' do + get '/api/v3/scenarios', headers: { 'Cookie' => "etm_session=#{jwt}" } + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)['data'].pluck('id')).to include(owned.id) + end + + it 'is unauthenticated without the cookie' do + get '/api/v3/scenarios' + + expect(response).to have_http_status(:forbidden) + end +end diff --git a/spec/requests/api/v3/update_input_spec.rb b/spec/requests/api/v3/update_input_spec.rb index f29c62153..dd2e934ae 100644 --- a/spec/requests/api/v3/update_input_spec.rb +++ b/spec/requests/api/v3/update_input_spec.rb @@ -113,7 +113,7 @@ def autobalance_scenario(values: {}, params: {}, headers: {}) end it 'responds 200 OK' do - decoded_token = ETEngine::TokenDecoder.decode(token_header['Authorization'].split(' ').last) + Identity::TokenDecoder.decode(token_header['Authorization'].split.last) expect(response.status).to be(200) end @@ -122,7 +122,7 @@ def autobalance_scenario(values: {}, params: {}, headers: {}) end it 'includes the scenario data' do - json = JSON.parse(response.body) + json = response.parsed_body expect(json).to have_key('scenario') diff --git a/spec/support/authorization_helper.rb b/spec/support/authorization_helper.rb index ecf431208..ca321a191 100644 --- a/spec/support/authorization_helper.rb +++ b/spec/support/authorization_helper.rb @@ -10,15 +10,11 @@ def access_token_header(user = nil, scopes = []) end def generate_jwt(user, **kwargs) - allow(ETEngine::TokenDecoder) - .to receive(:jwk).and_return( - JSON::JWK.new(AuthorizationHelper.key.public_key) - ) + allow(Identity::TokenDecoder).to receive(:jwk_set).and_return( + 'keys' => [JWT::JWK.new(AuthorizationHelper.key.public_key, 'test_key').export] + ) - token = JSON::JWT.new(jwt_payload(user, **kwargs)) - token.header[:kid] = 'test_key' - - token.sign(AuthorizationHelper.key, :RS256).to_s + JWT.encode(jwt_payload(user, **kwargs), AuthorizationHelper.key, 'RS256', kid: 'test_key') end def jwt_payload( From f09adcf1e00b26ec743169f94b049344cdfdfd18 Mon Sep 17 00:00:00 2001 From: louispt1 Date: Tue, 7 Jul 2026 16:29:28 +0200 Subject: [PATCH 2/7] Keep the shared session alive and recover it on etengine web pages --- Gemfile | 2 +- Gemfile.lock | 4 ++-- .../controllers/session_keeper_controller.js | 18 ++++++++++++++++++ app/views/layouts/application.html.haml | 2 +- config/importmap.rb | 1 + 5 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 app/javascript/controllers/session_keeper_controller.js diff --git a/Gemfile b/Gemfile index 3e242fd62..699cef2e5 100644 --- a/Gemfile +++ b/Gemfile @@ -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: 'ec2dfc6', github: 'quintel/identity_rails' +gem 'identity', ref: '7590604', github: 'quintel/identity_rails' gem 'turbine-graph', '>=0.1', require: 'turbine' # system gems diff --git a/Gemfile.lock b/Gemfile.lock index bcd291d66..a7db97096 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,8 +21,8 @@ GIT GIT remote: https://github.com/quintel/identity_rails.git - revision: ec2dfc6f2e6c6584fdfc2c3673bfb3bfb487d0aa - ref: ec2dfc6 + revision: 759060417940e07220252ad4d60bad72afcb90c1 + ref: 7590604 specs: identity (0.1.0) dry-configurable (>= 1.0) diff --git a/app/javascript/controllers/session_keeper_controller.js b/app/javascript/controllers/session_keeper_controller.js new file mode 100644 index 000000000..2d48e75d5 --- /dev/null +++ b/app/javascript/controllers/session_keeper_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus"; +import { startSessionKeeper } from "identity/session_keeper"; + +// Connects to data-controller="session-keeper" on . 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?.(); + } +} diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 292ff09a4..491e751f8 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -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 diff --git a/config/importmap.rb b/config/importmap.rb index cec5e0f9a..95407e037 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -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 From 5a9109943397b125b514ae490bfb67b770155998 Mon Sep 17 00:00:00 2001 From: louispt1 Date: Tue, 21 Jul 2026 09:01:23 +0200 Subject: [PATCH 3/7] Bump identity ref and rubocop changes --- Gemfile | 2 +- Gemfile.lock | 25 +++-------------------- app/controllers/application_controller.rb | 6 +----- config/importmap.rb | 2 ++ config/initializers/cors.rb | 12 ++++++----- 5 files changed, 14 insertions(+), 33 deletions(-) diff --git a/Gemfile b/Gemfile index 699cef2e5..208b51713 100644 --- a/Gemfile +++ b/Gemfile @@ -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: '7590604', github: 'quintel/identity_rails' +gem 'identity', ref: '44e9dcd', github: 'quintel/identity_rails' gem 'turbine-graph', '>=0.1', require: 'turbine' # system gems diff --git a/Gemfile.lock b/Gemfile.lock index a7db97096..9d8ac194f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,8 +21,8 @@ GIT GIT remote: https://github.com/quintel/identity_rails.git - revision: 759060417940e07220252ad4d60bad72afcb90c1 - ref: 7590604 + revision: 44e9dcdbe994bbade8ede0489e20e00f19dc3594 + ref: 44e9dcd specs: identity (0.1.0) dry-configurable (>= 1.0) @@ -31,9 +31,7 @@ GIT dry-validation (>= 1.10) faraday (>= 2) jwt (>= 2.5) - omniauth (>= 2.1) - omniauth-rails_csrf_protection (~> 1.0) - omniauth_openid_connect (~> 0.4) + openid_connect (>= 2.2) rails (>= 7.0.0) GIT @@ -327,8 +325,6 @@ GEM temple (>= 0.8.2) thor tilt - hashie (5.1.0) - logger highline (3.1.2) reline http-accept (1.7.0) @@ -458,17 +454,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) @@ -549,10 +534,6 @@ GEM 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) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 8fe918028..797f64230 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,11 +22,7 @@ def initialize_memory_cache def current_user @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 + (User.from_jwt!(identity_token) if identity_token) rescue ActiveRecord::RecordNotFound reset_session redirect_to root_path diff --git a/config/importmap.rb b/config/importmap.rb index 95407e037..9633bd7e2 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Pin npm packages by running ./bin/importmap pin 'identity', preload: true diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 8141c1c28..a221c01f8 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -1,20 +1,22 @@ +# 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; override with CORS_SESSION_ORIGINS (comma-separated) if needed. +# ETM subdomain. SESSION_CORS_ORIGINS = - ENV["CORS_SESSION_ORIGINS"].to_s.split(",").map(&:strip).presence || [ + 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 +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] + 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. @@ -22,6 +24,6 @@ origins '*' resource '/api/*', headers: :any, - methods: [:get, :post, :put, :patch, :delete, :options, :head] + methods: %i[get post put patch delete options head] end end From 4a3b4e43d425510d92710652e3741939bde41a48 Mon Sep 17 00:00:00 2001 From: louispt1 Date: Tue, 21 Jul 2026 11:27:48 +0200 Subject: [PATCH 4/7] Remove scope roles from oauth config --- Gemfile | 2 +- Gemfile.lock | 36 ++------------------------- config/initializers/identity.rb | 1 - spec/models/api/token_ability_spec.rb | 31 ----------------------- 4 files changed, 3 insertions(+), 67 deletions(-) diff --git a/Gemfile b/Gemfile index 208b51713..cff2c4400 100644 --- a/Gemfile +++ b/Gemfile @@ -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: '44e9dcd', github: 'quintel/identity_rails' +gem 'identity', ref: 'f902106', github: 'quintel/identity_rails' gem 'turbine-graph', '>=0.1', require: 'turbine' # system gems diff --git a/Gemfile.lock b/Gemfile.lock index 9d8ac194f..9fb1576bc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,8 +21,8 @@ GIT GIT remote: https://github.com/quintel/identity_rails.git - revision: 44e9dcdbe994bbade8ede0489e20e00f19dc3594 - ref: 44e9dcd + revision: f9021064e2bced6e86f7301dea4c0bc09082757b + ref: f902106 specs: identity (0.1.0) dry-configurable (>= 1.0) @@ -31,7 +31,6 @@ GIT dry-validation (>= 1.10) faraday (>= 2) jwt (>= 2.5) - openid_connect (>= 2.2) rails (>= 7.0.0) GIT @@ -162,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) @@ -175,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) @@ -265,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) @@ -355,13 +349,6 @@ 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) @@ -527,13 +514,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-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -690,11 +670,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) @@ -726,9 +701,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) @@ -743,10 +715,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 diff --git a/config/initializers/identity.rb b/config/initializers/identity.rb index 85f5a0d87..f47c8ce2b 100644 --- a/config/initializers/identity.rb +++ b/config/initializers/identity.rb @@ -30,7 +30,6 @@ config.client_uri = Settings.identity.client_uri config.client_id = Settings.identity.client_id config.client_secret = Settings.identity.client_secret - config.scope = 'openid profile email roles scenarios:read scenarios:write scenarios:delete' config.validate_config = ENV['DOCKER_BUILD'] != 'true' # No resource app configured - ETModel is no longer a resource config.resource_uri = '' diff --git a/spec/models/api/token_ability_spec.rb b/spec/models/api/token_ability_spec.rb index fd4b346e5..6173f8ecb 100644 --- a/spec/models/api/token_ability_spec.rb +++ b/spec/models/api/token_ability_spec.rb @@ -5,20 +5,6 @@ RSpec.describe Api::TokenAbility do let(:user) { create(:user, roles:) } let(:roles) { :scenario_viewer } - let(:test_token) { JSON.parse(File.read(Rails.root.join('spec/fixtures/identity/token/idp_token.json')))['token'] } - let(:mock_jwk_set) do - { - keys: [ - { - kty: 'RSA', - kid: 'test-key-id', - use: 'sig', - n: 'test-modulus', - e: 'AQAB' - } - ] - } - end let(:scopes) { '' } let(:mock_decoded_token) do @@ -31,23 +17,6 @@ }.with_indifferent_access end - before do - # Stub Faraday to prevent actual HTTP requests - allow(Faraday).to receive(:new).and_return( - double('Faraday::Connection').tap do |connection| - allow(connection).to receive(:get).and_return( - double('Faraday::Response', body: mock_jwk_set.to_json) - ) - end - ) - - # Stub the jwk_set method to return the mock JWK set - allow(described_class).to receive(:jwk_set).and_return(JSON::JWK::Set.new(mock_jwk_set)) - - # Mock the TokenDecoder behavior - allow(Identity::TokenDecoder).to receive(:decode).with(test_token).and_return(mock_decoded_token) - end - let(:ability) { described_class.new(mock_decoded_token, user) } let!(:public_scenario) { create(:scenario, user: nil, private: false) } From a72c7808fee0935bf9302d5863053264b5bb744c Mon Sep 17 00:00:00 2001 From: louispt1 Date: Thu, 23 Jul 2026 12:08:50 +0200 Subject: [PATCH 5/7] Remove outdated authentication documentation --- docs/authentication.md | 251 ----------------------------------------- 1 file changed, 251 deletions(-) delete mode 100644 docs/authentication.md diff --git a/docs/authentication.md b/docs/authentication.md deleted file mode 100644 index a16d0eac7..000000000 --- a/docs/authentication.md +++ /dev/null @@ -1,251 +0,0 @@ -# Authentication - -## Introduction - -ETEngine is used to authenticate users. [Devise](https://github.com/heartcombo/devise) handles new registrations, verifying and storing credentials, and user sessions. - -[Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper) is used to provide support for OAuth2 and authorizing users, with [Doorkeeper::OpenidConnect](https://github.com/doorkeeper-gem/doorkeeper-openid_connect) taking care of authentication with OpenID Connect. - -Third-party authentication services such as Auth0 were considered but eventually discounted. Running our own authentication system: - -- Provides more control over the user experience. -- Allows us to use OAuth access tokens as personal access tokens for API requests. -- Makes it easier for staff and third-party developers to run the ETM without having to set up a separate service. -- Avoids potentially prohibitive pricing when exceeding plan limits. - -## Terminology - -- **Provider**: The OpenID Connect provider exposed by ETEngine. This issues tokens allowing access to the API. -- **Application**: A client application registered with ETEngine. This is used to store the client's credentials, and to allow the client to request tokens. Applications include ETModel, Transition Paths, and (potentially) third-party services. -- **Client**: An application that uses the API. This could be a web application, a mobile application, or a third-party service. -- **Access token**: A token issued by the provider which allows access to the API. -- **Refresh token**: A token issued by the provider which allows a new access token to be requested without the user having to re-authenticate. -- **Scopes**: A list of permissions granted to the access token. These are defined by the provider and are used to limit the actions which can be performed with the token. - -## Tokens - -The provider issues tokens to clients; these tokens allow access to the API. - -Tokens are a random Base58 string, with Base58 being selected as it contains only alphanumeric characters, and is therefore safe to use in URLs. Unlike Base64, Base58 does not contain any characters which are likely to be confused with each other, such as 0 and O, nor characters which cause problems when double-clicking the token to copy/paste (such as dashes). - -### Token scopes - -Each token is assigned one or more [scopes](https://github.com/quintel/etengine/blob/ab6f07f23c8ff5d7bf9fe8fd95c3eff2ade4721e/config/initializers/doorkeeper.rb#L238-L245) that limit what actions may be performed with the token. The following scopes are available: - -- `public`: A default scope which has no practical effect (all tokens can read public data). -- `profile`: Allows the user's names to be retrieved from [the userinfo endpoint](https://docs.energytransitionmodel.com/api/authentication#get-information-about-the-current-user). -- `email`: Allows the user's email address to be retrieved from [the userinfo endpoint](https://docs.energytransitionmodel.com/api/authentication#get-information-about-the-current-user). -- `openid`: Allows the use of OpenID Connect. -- `roles`: Allows the user's roles to be retrieved (intended for first-party apps only). -- `scenarios:read`: Allows the user's public and private scenarios to be read. -- `scenarios:write`: Allows the token to be used to create and update the user's public and private scenario. -- `scenarios:delete`: Allows the token to be used to delete the user's public and private scenarios. - -For the moment, each level of `scenarios` scope requires all lower levels. This means that a token with `scenarios:write` must also have `scenarios:read` or else the `scenarios:write` permissions will not apply. This is not enforced, except through the web interface for generating personal tokens. The `scenarios` scopes also allow access to transition paths. - -The `roles` scope is intended for use by ETM and Quintel Intelligence applications only. It provides an array of roles which have been assigned to the user. For most this will be `["user"]`, but for ETM staff it will be `["user", "admin"]`. - -[Cancancan](https://github.com/CanCanCommunity/cancancan) is used to authorize tokens: - -- [`GuestAbility`](https://github.com/quintel/etengine/blob/master/app/models/api/guest_ability.rb) for unauthenticated API requests. -- [`TokenAbility`](https://github.com/quintel/etengine/blob/master/app/models/api/token_ability.rb) for authenticated API requests. - -### Token expiry and refresh tokens - -Access tokens issued by Doorkeeper are limited to a two-hour duration. Access tokens are issued with a [refresh token](https://oauth.net/2/grant-types/refresh-token/) allowing clients to request a new access token without the user having to re-authenticate. A relatively short duration has been selected for access tokens as this ensures that if a user accidentally exposes their access token, it will expire relatively quickly. - -Refresh tokens are not available to the browser in ETEngine, ETModel, or Transition Paths and are held only on the server. This ensures that only the signed-in user can create a new access token with a refresh token. - -Both ETModel and Transition Paths take care of refreshing the access token when it expires, and storing the new access token and refresh token in the browser's local storage. - -### Personal access tokens - -Some users wish to be able to use the API without having to go through the trouble of setting up an OAuth application. For this reason, personal access tokens are available. These are tokens which are not associated with a client and can be used to access the API directly. - -Users can generate personal access tokens from their profile page. These tokens are stored in the database and are not visible after they are generated. If a user loses their token, they can generate a new one. - -Authenticated requests to the API are sent with an `Authorization` header containing the token: - -```bash -curl https://engine.energytransitionmodel.com/api/v3/scenarios \ - -H 'Accept: application/json' \ - -H 'Authorization: Bearer etm_abc123' -``` - -Internally, personal access tokens are implemented as ordinary OAuth access tokens, with a second model – `PersonalAccessToken` – used to store additional data. The access tokens allow us to generate a unique token, assign the expiry and scopes, and revoke the token if necessary. The second model allows us to more easily identify which tokens are personal access tokens and permits users to assign a name to their token. - -Users will be e-mailed three days before the expiry of their token with a list of the assigned permissions. This is intended to remind users that they should generate a new token if they wish to continue using the API. - -### API requests to ETModel - -While users can sign in to ETModel and interact with the web application using ETEngine's authentication, private data held in ETModel cannot be exposed this way: an API request knows nothing about the user who made the request. Additionally, the personal access tokens issued to users are random strings and do not disclose any information about the user. - -[JSON Web Tokens (JWT)](https://jwt.io/introduction) were initially considered for use as access tokens, but suffer from the drawback that, once issued, it is not possible to force the expiry of a token. This means that if a user's access token is compromised, their data could be accessed by the attacker until the token expires. - -For these reasons, all API requests – even those intended for ETModel – are sent to ETEngine. ETEngine looks up the user by their alphanumeric personal access token, and then forwards the request to ETModel with [a short-life JWT containing the user information](https://github.com/quintel/etengine/blob/ab6f07f23c8ff5d7bf9fe8fd95c3eff2ade4721e/lib/etengine/auth.rb#L38-L55). This ensures that the user's identity is known to ETModel, and that the user's access token can still be revoked if necessary. - -These JWTs are never exposed to end-users and contain the following information: - -* `sub`: The user's ID. -* `iat`: The time the token was issued. -* `exp`: The time the token expires. -* `iss`: The issuer of the token (ETEngine). -* `aud`: The audience of the token (ETModel). -* `scopes`: The scopes granted to the token. These exactly match the scopes granted to the user's personal access token. -* `user`: An object containing the user ID and name. These are always provided even if the personal access token does not include the `profile` scope. - -All JWTs are signed with ETEngine's private key. The public key is available at [`/oauth/discovery/keys`](https://engine.energytransitionmodel.com/oauth/discovery/keys). ETModel retrieves this key (ideally when it starts up) and uses it to verify the signature of the JWT. This ensures that the token has not been tampered with and cannot be forged by an attacker. - -ETModel will also verify that the token was issued (`iss`) by ETEngine, and was intended for ETModel (`aud`). - -#### Request-response flow - -1. ETEngine receives a request from an API user. -2. ETEngine checks the scopes of the token to verify that the user has permission to access the requested data. -3. ETEngine generates a JWT containing the user information and forwards the request to ETModel. -4. ETModel receives the request, verifies the JWT using ETEngine's public key, and checks the token scope. -5. ETModel responds with the requested data. -6. ETEngine receives the response from ETModel and adds additional information if necessary and replies to the API user. - -ETModel [has dedicated controllers](https://github.com/quintel/etmodel/tree/master/app/controllers/api) (the API namespace) for handling requests from ETEngine. - -#### Sending a request to ETModel - -ETEngine provides helper methods to create a Faraday client which can send authenticated requests to ETModel: - -```ruby -user = User.find(123) -client = ETEngine::Auth.etmodel_client(user) - -# The client is automatically configured with the ETModel URL and the signed -# JWT as a bearer token. -client.get('/api/v1/saved_scenarios') -``` - -## Identity gem - -A Ruby Gem - [Identity](https://github.com/quintel/identity_rails) – has been written which takes care of the details of authenticating with the provider and requesting access tokens. The Gem is a Rails engine which provides a controller and views for authenticating with the provider, and requesting access tokens. It also provides an HTTP client ([via Faraday](https://lostisland.github.io/faraday/)) for making requests to the API. - -```ruby -Identity.http_client.get('/api/v3/scenarios') -# => # -``` - -If you have the `Identity::AccessToken` available, it too exposes an HTTP client which will automatically add the access token to the request: - -```ruby -access_token.http_client.get('/api/v3/scenarios') -``` - -### Use of Identity in ETModel - -ETModel further abstracts this complexity by [providing an `engine_client` helper method](https://github.com/quintel/etmodel/blob/35f22bcbfebc39e55ac9b0e5f813f7cac06ceee7/app/controllers/application_controller.rb#L99-L107) to all controllers. This will send authenticated requests when the user is signed in, and unauthenticated requests when the user is not. - -Furthermore, ETModel also stores a minimal copy of the user information: the user ID and name. This allows us to associate data with users and show their names without having to make an API request to ETEngine. When the user updates their profile, ETEngine [will forward this information to ETModel](https://github.com/quintel/etengine/blob/master/app/jobs/identity/sync_user_job.rb) ensuring the data is kept up-to-date. In the event this fails, the user's name will be updated the next time they sign in. - -### Configuring Identity - -Since ETModel authenticates with ETEngine, staff must create an OAuth application and configure ETModel with the `client_id` and `client_secret`. This is simplified by allowing staff to create an "ETModel (Local)" application in ETEngine. They will be provided with a config snippet which includes all necessary configurations for connecting to ETEngine. - -When running locally, ETEngine will seek to preserve any such "staff applications" and their secrets, avoiding the need for staff to generate a new secret each time they import a new database. - -## Changes to ETEngine - -The authentication project introduced many changes to ETEngine. - -### Private and public key - -ETEngine now has a private key which is used by OpenID Connect and to sign JWTs. This key is expected to exist at `tmp/openid.key`. - -- In production environments, this key is stored on the server and mounted in the Docker container. -- In local environments, ETEngine [will generate and store an RSA key pair](https://github.com/quintel/etengine/blob/ab6f07f23c8ff5d7bf9fe8fd95c3eff2ade4721e/lib/etengine/auth.rb#L22-L27) if one does not exist avoiding the need for staff to do this manually. - -### Authenticated API requests - -ETEngine can continue to be used as before; users can send unauthenticated requests to the API to create and update scenarios. However, unauthenticated requests have some limitations: - -- They will only be able to access public scenarios. -- They cannot modify scenarios which belong to a user. -- They cannot list scenarios. -- They cannot delete scenarios. - -When a request is authenticated, and assuming it has the necessary scopes, it will be able to access all scenarios belonging to the user, list their scenarios and saved scenarios, and delete scenarios. Naturally, authenticated requests cannot access private scenarios belonging to other users, nor delete scenarios which belong to other users. - -The result is that for authenticated users, the scenarios API is significantly more powerful. They can easily list and delete their scenarios, and control who is allowed to access their scenarios. - -Scenarios created with an access token are associated with the user who created them. Only this user can change the scenario. This prevents other users from modifying scenarios which belong to others. Critically, this ensures that users can be confident their scenarios have not been modified by other people and are exactly as they left them. - -### Private scenarios - -Authenticated users can create private scenarios and saved scenarios. This allows only the user to view the data. - -Transition paths have no private/public setting, but can only be listed, viewed, modified, or deleted through the API by their owner and are therefore effectively **private**. - -### User preferences - -Users can set their e-mail address and name. Changes to their e-mail address will trigger a message to both addresses and will require the new address to be confirmed before it becomes active. - -Users can also set whether they wish their scenarios to be public or private by default. API users can set this on a per scenario basis, and all users can override this setting per scenario when they save the scenario in ETModel. At the time of writing, the default setting is **public**. - -### User data - -User data from ETModel has been imported into ETEngine. This ensures that the existing userbase can continue using the ETM without interruption. The user data is stored in a new `users` table in the ETEngine database. - -Unfortunately, the authentication system used by ETEngine stored passwords as a salted SHA256 hash whereas Devise uses the superior BCrypt algorithm. This means that we had to store the SHA256 hash and salt for each user, and [migrate them to BCrypt the next time they sign in](https://github.com/quintel/etengine/blob/ab6f07f23c8ff5d7bf9fe8fd95c3eff2ade4721e/app/controllers/users/sessions_controller.rb#L50-L66). In time, this can likely be removed with non-migrated users expected to reset their password. - -### User pages - -New user pages have been added, allowing users to view and edit their profile and settings. These pages make extensive use of features added in Rails 7: Turbo, Turbo Frames, and Stimulus, to provide a nice user experience. ViewComponent has been adopted along with Tailwind CSS for styling. This keeps view code simple and (where needed) easily testable. - -## Future improvements - -### Delete accounts - -GDPR requires that users can delete their accounts. It does not require that this process be automated, but it is a good idea to provide this functionality. This is not currently implemented. - -Account deletion should require the user to verify their request (by re-entering their password). Then, ETEngine should delete all data associated with the account and send a request to ETModel to do likewise. A new authenticated API endpoint will need to be added to ETModel to allow this. - -### Move transition paths to ETEngine - -Transition Path data could be moved out of ETModel and into ETEngine. The pages used to select an existing transition part could be moved to the Transition Path application, with it querying the ETEngine API for the data. This would allow the Transition Path application to be used independently of ETModel. - -### Allow third-party applications - -There is no web interface for creating new OAuth applications. It would be nice to allow third parties (for example, the CTM) to be able to register their applications and access ETM data (when authorized by the user). - -A page already exists which shows users [their authorized applications](https://engine.energytransitionmodel.com/oauth/authorized_applications) and allows them to revoke access. - -### Single sign-out - -Currently, users must sign out of each application separately. It would be nice to allow users to sign out of all applications at once. - -- If a user signs out of ETModel or the Transition Paths application, they will also be signed out of ETEngine. -- If a user signs out of ETEngine, they will not be signed out of ETModel or the Transition Paths application. - -I believe this could be implemented in Doorkeeper by keeping track of which applications a user has signed in to. When a user signs out of one application, it could send a request to the other applications to sign the user out. This would require a new API endpoint in ETEngine and ETModel. - -```ruby -# config/initializers/doorkeeper.rb - -after_successful_authorization do |controller, context| - controller.session[:logout_application_ids] << - Doorkeeper::Application.find_by(controller.request.params.slice(:client_id)).id -end -``` - -When a user signs out: - -1. Retrieve the list of application IDs from the session. -2. With the first application in the list: - - Get the log out URL for the application. - - Redirect the user to the logout URL. - - The client application must now redirect back to ETEngine. -3. If there are more applications from which to sign out, go to step 2, otherwise to step 4. -3. When there are no more applications from which to sign out, remove the ETEngine session signing the user out. - -A downside of this is that each application needs to have a GET endpoint for signing the user out and most browsers limit the number of allowed redirects per request (Chrome sets this to 20). From 184b64793b11b988471fa73b62d1163c21b56c4d Mon Sep 17 00:00:00 2001 From: louispt1 Date: Tue, 28 Jul 2026 12:12:02 +0200 Subject: [PATCH 6/7] Updates based on env scoped sso cookie token --- Gemfile | 2 +- Gemfile.lock | 4 ++-- .../controllers/session_keeper_controller.js | 12 ++++++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index cff2c4400..a9fafb778 100644 --- a/Gemfile +++ b/Gemfile @@ -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: 'f902106', github: 'quintel/identity_rails' +gem 'identity', ref: 'cbcdeb6', github: 'quintel/identity_rails' gem 'turbine-graph', '>=0.1', require: 'turbine' # system gems diff --git a/Gemfile.lock b/Gemfile.lock index 9fb1576bc..7823e9992 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,8 +21,8 @@ GIT GIT remote: https://github.com/quintel/identity_rails.git - revision: f9021064e2bced6e86f7301dea4c0bc09082757b - ref: f902106 + revision: cbcdeb6b6bcf470da44a301f5e6445e172144e9c + ref: cbcdeb6 specs: identity (0.1.0) dry-configurable (>= 1.0) diff --git a/app/javascript/controllers/session_keeper_controller.js b/app/javascript/controllers/session_keeper_controller.js index 2d48e75d5..e58b9f355 100644 --- a/app/javascript/controllers/session_keeper_controller.js +++ b/app/javascript/controllers/session_keeper_controller.js @@ -6,10 +6,18 @@ import { startSessionKeeper } from "identity/session_keeper"; // 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 }; + // 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 }); + this.teardown = startSessionKeeper({ + idpUrl: this.idpUrlValue, + expCookieName: this.expCookieValue, + }); } disconnect() { From bfd4254553ee9b5a9d7259e63daec1ff24c2d4f0 Mon Sep 17 00:00:00 2001 From: louispt1 Date: Wed, 5 Aug 2026 11:42:23 +0200 Subject: [PATCH 7/7] Bump identity ref --- Gemfile | 2 +- Gemfile.lock | 19 ++----------------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/Gemfile b/Gemfile index a9fafb778..675d1da8a 100644 --- a/Gemfile +++ b/Gemfile @@ -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: 'cbcdeb6', github: 'quintel/identity_rails' +gem 'identity', ref: 'd88af33', github: 'quintel/identity_rails' gem 'turbine-graph', '>=0.1', require: 'turbine' # system gems diff --git a/Gemfile.lock b/Gemfile.lock index 7823e9992..f4f921759 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,8 +21,8 @@ GIT GIT remote: https://github.com/quintel/identity_rails.git - revision: cbcdeb6b6bcf470da44a301f5e6445e172144e9c - ref: cbcdeb6 + revision: d88af3330404aac6fee7e3b027ec3096ec2bba8f + ref: d88af33 specs: identity (0.1.0) dry-configurable (>= 1.0) @@ -278,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) @@ -441,19 +439,6 @@ GEM nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) numo-narray (0.9.2.1) - 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)