diff --git a/.semaphore/deploy_production.yml b/.semaphore/deploy_production.yml index 0f9e91c04..d0db6aad0 100644 --- a/.semaphore/deploy_production.yml +++ b/.semaphore/deploy_production.yml @@ -3,7 +3,7 @@ name: Deploy to production agent: machine: type: e1-standard-2 - os_image: ubuntu2004 + os_image: ubuntu2404 blocks: - name: Docker task: diff --git a/.semaphore/deploy_stable.yml b/.semaphore/deploy_stable.yml index e019c336f..fefbce3b4 100644 --- a/.semaphore/deploy_stable.yml +++ b/.semaphore/deploy_stable.yml @@ -3,7 +3,7 @@ name: Deploy to stable agent: machine: type: e1-standard-2 - os_image: ubuntu2004 + os_image: ubuntu2404 execution_time_limit: hours: 10 blocks: diff --git a/.semaphore/deploy_staging.yml b/.semaphore/deploy_staging.yml index f58ef58f1..4b139c8cc 100644 --- a/.semaphore/deploy_staging.yml +++ b/.semaphore/deploy_staging.yml @@ -3,7 +3,7 @@ name: Deploy to staging agent: machine: type: e1-standard-2 - os_image: ubuntu2004 + os_image: ubuntu2404 execution_time_limit: hours: 10 blocks: diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index a28099004..02218ab31 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -3,7 +3,7 @@ name: Test ETEngine agent: machine: type: e1-standard-2 - os_image: ubuntu2004 + os_image: ubuntu2404 execution_time_limit: hours: 6 blocks: @@ -19,6 +19,8 @@ blocks: commands: - checkout - sem-service start mysql 8 + - sudo apt-get update + - sudo apt-get install -y libmysqlclient-dev - cache restore - gem install bundler - bundle config set --local deployment 'true' diff --git a/Gemfile b/Gemfile index 58245be5a..bac903e34 100644 --- a/Gemfile +++ b/Gemfile @@ -33,7 +33,7 @@ gem 'kaminari' # Authentication and authorization gem 'cancancan', '~> 3.0' -gem 'identity', ref: 'e18aa91', github: 'quintel/identity_rails' +gem 'identity', ref: 'd88af33', github: 'quintel/identity_rails' gem 'activerecord-session_store' gem 'sidekiq' diff --git a/Gemfile.lock b/Gemfile.lock index f5257ee1b..64085db2e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,8 +20,8 @@ GIT GIT remote: https://github.com/quintel/identity_rails.git - revision: e18aa910c7df04b60b839dc5f81df6487381284f - ref: e18aa91 + revision: d88af3330404aac6fee7e3b027ec3096ec2bba8f + ref: d88af33 specs: identity (0.1.0) dry-configurable (>= 1.0) @@ -29,9 +29,7 @@ GIT 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 @@ -163,9 +161,7 @@ GEM tzinfo (~> 2.0) addressable (2.8.1) public_suffix (>= 2.0.2, < 6.0) - aes_key_wrap (1.1.0) ast (2.4.2) - attr_required (1.0.2) axiom-types (0.1.1) descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) @@ -177,7 +173,6 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) bigdecimal (3.1.9) - bindata (2.5.0) binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) bootsnap (1.10.3) @@ -263,8 +258,6 @@ GEM dry-initializer (~> 3.0) dry-schema (>= 1.12, < 2) zeitwerk (~> 2.6) - email_validator (2.2.4) - activemodel equalizer (0.0.11) erb-formatter (0.7.3) syntax_tree (~> 6.0) @@ -279,8 +272,6 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.3.0) - faraday (>= 1, < 3) faraday-net_http (3.4.0) net-http (>= 0.5.0) ffi (1.15.5) @@ -296,7 +287,6 @@ GEM temple (>= 0.8.2) thor tilt - hashie (5.0.0) highline (2.0.3) http-accept (1.7.0) http-cookie (1.0.4) @@ -325,13 +315,8 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.6.1) - json-jwt (1.16.7) - activesupport (>= 4.2) - aes_key_wrap + jwt (3.2.0) base64 - bindata - faraday (~> 2.0) - faraday-follow_redirects kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -407,29 +392,6 @@ GEM nokogiri (1.18.5-x86_64-linux-gnu) racc (~> 1.4) numo-narray (0.9.2.1) - omniauth (2.1.2) - hashie (>= 3.4.6) - 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) parallel (1.22.1) parser (3.2.0.0) ast (~> 2.4.1) @@ -455,16 +417,6 @@ GEM rack (2.2.13) rack-cors (1.1.1) rack (>= 2.0.0) - rack-oauth2 (2.2.1) - activesupport - attr_required - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.11.0) - rack (>= 2.1.0) - rack-protection (3.2.0) - base64 (>= 0.1.0) - rack (~> 2.2, >= 2.2.4) rack-session (1.0.2) rack (< 3) rack-test (2.2.0) @@ -606,11 +558,6 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) - swd (2.0.3) - activesupport (>= 3) - attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects syntax_tree (6.2.0) prettier_print (>= 1.2.0) tailwindcss-rails (3.0.0) @@ -641,9 +588,6 @@ GEM unf_ext (0.0.8) unicode-display_width (2.4.2) uri (1.0.3) - validate_url (1.0.15) - activemodel (>= 3.0.0) - public_suffix view_component (3.17.0) activesupport (>= 5.2.0, < 8.0) concurrent-ruby (~> 1.0) @@ -658,10 +602,6 @@ GEM nokogiri (~> 1.6) rubyzip (>= 1.3.0) selenium-webdriver (~> 4.0) - webfinger (2.1.3) - activesupport - faraday (~> 2.0) - faraday-follow_redirects webrick (1.9.1) websocket (1.2.9) websocket-driver (0.7.7) diff --git a/app/controllers/api/v3/base_controller.rb b/app/controllers/api/v3/base_controller.rb index 889edd28f..0f93c15c5 100644 --- a/app/controllers/api/v3/base_controller.rb +++ b/app/controllers/api/v3/base_controller.rb @@ -2,6 +2,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 @@ -26,10 +27,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]) @@ -46,27 +43,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? - - request.authorization.to_s.match(/\ABearer (.+)\z/) do |match| - return @token = ETEngine::TokenDecoder.decode(match[1]) - end - 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..797f64230 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/javascript/controllers/session_keeper_controller.js b/app/javascript/controllers/session_keeper_controller.js new file mode 100644 index 000000000..e58b9f355 --- /dev/null +++ b/app/javascript/controllers/session_keeper_controller.js @@ -0,0 +1,26 @@ +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 { + // 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?.(); + } +} diff --git a/app/models/user.rb b/app/models/user.rb index 3bd7dc9b4..6f8daf5d6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,11 +9,10 @@ class User < ApplicationRecord attr_accessor :identity_user - delegate :roles, :admin?, to: :identity_user, allow_nil: true + delegate :roles, to: :identity_user, allow_nil: true has_many :scenario_users, dependent: :destroy has_many :scenarios, through: :scenario_users has_many :scenario_version_tags - has_many :personal_access_tokens, dependent: :destroy validates :name, presence: true @@ -46,22 +45,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 +58,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 +77,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 21ee58b92..69b1194f0 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -14,7 +14,7 @@ = stylesheet_link_tag 'application' = 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 31f33850a..1a966d3fb 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..9633bd7e2 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -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 diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 31e0c9f4b..a221c01f8 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -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 diff --git a/config/initializers/identity.rb b/config/initializers/identity.rb index 8cf51f5a0..f47c8ce2b 100644 --- a/config/initializers/identity.rb +++ b/config/initializers/identity.rb @@ -30,61 +30,16 @@ 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 = '' - - # 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 8e98cf2fd..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 - Rails.logger.warn( - "issuer #{decoded[:iss] == Settings.identity.issuer}: #{decoded[:iss]} \ - aud #{decoded[:aud].include?(Settings.identity.client_uri)} #{decoded[:aud]} \ - sub #{decoded[:sub].present?} \ - exp #{decoded[:exp] > Time.now.to_i} #{decoded[:exp]} - " - ) - raise DecodeError, 'JWT verification failed' - end - - decoded - end - - # Fetches and caches the JWK from the IdP. - def jwk - jwk_cache.fetch('jwk_hash') do - client = Faraday.new(Identity.discovery_config.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 - - # 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..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(ETEngine::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) } 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 09cf9fc74..07d34cf52 100644 --- a/spec/requests/api/v3/update_input_spec.rb +++ b/spec/requests/api/v3/update_input_spec.rb @@ -112,7 +112,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 @@ -121,7 +121,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(