diff --git a/Gemfile b/Gemfile index a830f153a..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: '26f582e', 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 dfe2deaa4..f4f921759 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: d88af3330404aac6fee7e3b027ec3096ec2bba8f + ref: d88af33 specs: identity (0.1.0) dry-configurable (>= 1.0) @@ -30,9 +30,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 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) @@ -176,7 +172,6 @@ GEM rack (>= 0.9.0) rouge (>= 1.0.0) bigdecimal (4.1.2) - bindata (2.5.1) binding_of_caller (2.0.0) debug_inspector (>= 1.2.0) bootsnap (1.23.0) @@ -266,8 +261,6 @@ GEM dry-initializer (~> 3.2) dry-schema (~> 1.14) zeitwerk (~> 2.6) - email_validator (2.2.4) - activemodel equalizer (0.0.11) erb (6.0.4) erb-formatter (0.7.3) @@ -285,8 +278,6 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.5.0) - faraday (>= 1, < 3) faraday-net_http (3.4.4) net-http (~> 0.5) ffi (1.17.3) @@ -326,8 +317,6 @@ GEM temple (>= 0.8.2) thor tilt - hashie (5.1.0) - logger highline (3.1.2) reline http-accept (1.7.0) @@ -358,16 +347,11 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (2.19.7) - json-jwt (1.17.0) - activesupport (>= 4.2) - aes_key_wrap - base64 - bindata - faraday (~> 2.0) - faraday-follow_redirects json-schema (6.2.0) addressable (~> 2.8) bigdecimal (>= 3.1, < 5) + jwt (3.2.0) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -455,30 +439,6 @@ GEM nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) numo-narray (0.9.2.1) - omniauth (2.1.4) - hashie (>= 3.4.6) - logger - rack (>= 2.2.3) - rack-protection - omniauth-rails_csrf_protection (1.0.2) - actionpack (>= 4.2) - omniauth (~> 2.0) - omniauth_openid_connect (0.8.0) - omniauth (>= 1.9, < 3) - openid_connect (~> 2.2) - openid_connect (2.3.1) - activemodel - attr_required (>= 1.0.0) - email_validator - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.16) - mail - rack-oauth2 (~> 2.2) - swd (~> 2.0) - tzinfo - validate_url - webfinger (~> 2.0) opentelemetry-api (1.11.0) logger opentelemetry-common (0.25.1) @@ -539,17 +499,6 @@ GEM rack-cors (3.0.0) logger rack (>= 3.0.14) - rack-oauth2 (2.3.0) - activesupport - attr_required - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.11.0) - rack (>= 2.1.0) - rack-protection (4.2.1) - base64 (>= 0.1.0) - logger (>= 1.6.0) - rack (>= 3.0.0, < 4) rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -706,11 +655,6 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) - swd (2.0.3) - activesupport (>= 3) - attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects syntax_tree (6.3.0) prettier_print (>= 1.2.0) tailwindcss-rails (3.3.2) @@ -742,9 +686,6 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) - validate_url (1.0.15) - activemodel (>= 3.0.0) - public_suffix view_component (4.10.0) actionview (>= 7.1.0) activesupport (>= 7.1.0) @@ -759,10 +700,6 @@ GEM nokogiri (~> 1.6) rubyzip (>= 1.3.0) selenium-webdriver (~> 4.0) - webfinger (2.1.3) - activesupport - faraday (~> 2.0) - faraday-follow_redirects websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -864,4 +801,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..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 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 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/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/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') -# => #