diff --git a/Gemfile b/Gemfile index 424c05c42..7591c988f 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: '7590604', github: 'quintel/identity_rails' gem 'turbine-graph', '>=0.1', require: 'turbine' # system gems diff --git a/Gemfile.lock b/Gemfile.lock index 21c208276..a38bc182e 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: 759060417940e07220252ad4d60bad72afcb90c1 + ref: 7590604 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) @@ -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) @@ -814,4 +817,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/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/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/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/environments/development.rb b/config/environments/development.rb index 06decff49..5294948cf 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 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 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(