diff --git a/Gemfile.lock b/Gemfile.lock index 866d9a5..5045dfe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ PATH 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) @@ -176,6 +177,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jwt (3.2.0) + base64 logger (1.7.0) loofah (2.25.0) crass (~> 1.0.2) diff --git a/README.md b/README.md index 7be2d38..98bbb2f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,16 @@ ETM's [Identity](https://id.energytransitionmodel.com/users/sign_in) application helpers for requiring that a user (or admin) be signed in to use controllers or actions, as well as standard pages requesting the user sign in. +## How sign-in works + +The browser session is a single JWT, held in a cookie (`etm_session` by default) scoped to the +parent domain shared by every ETM app. The provider (MyETM) mints and refreshes this cookie; every +other app only ever reads and verifies it — there is no local session state to keep in sync. The +"Sign in" link still routes the visitor through the provider's login page (so a fresh browser is +prompted to authenticate), but the cookie is what makes the visitor "signed in" here, both for +resource-server requests (`Identity::ResourceServer`, which also accepts the same cookie value as a +bearer token) and for a normal signed-in page load (`Identity::ControllerHelpers`). + ## Installation Add the engine to the Rails application Gemfile: @@ -45,13 +55,14 @@ before_action :authenticate_admin! Identical to `authenticate_user!` except that the signed-in user must also have the `admin` role. -#### `current_user` +#### `identity_user` -Returns the current user, if signed in, or nil otherwise. +Returns the current `Identity::User`, if signed in, or nil otherwise. #### `signed_in?` -Returns whether the visitor is signed in. +Returns whether the visitor is signed in (i.e. whether the shared session cookie is present and +verifies). #### `sign_in_path` / `sign_out_page` @@ -61,11 +72,6 @@ show a sign in prompt, while POST sends the user to the identity provider. Signing out is only possible with a POST request. The user will be signed out of the application _and_ the identity provider, and will finally be redirected back to the root of your application. -#### `identity_session` - -Returns the current `Identity::Session` if the visitor is signed in. This gives access to the user -and a copy of the access token which can be used to send further requests to the identity provider. - ## License The gem is available as open source under the terms of the diff --git a/app/assets/config/identity_manifest.js b/app/assets/config/identity_manifest.js index ddbbc8b..89a57aa 100644 --- a/app/assets/config/identity_manifest.js +++ b/app/assets/config/identity_manifest.js @@ -1,2 +1,3 @@ //= link_tree ../images //= link_directory ../stylesheets/identity .css +//= link identity/session_keeper.js diff --git a/app/assets/javascripts/identity/session_keeper.js b/app/assets/javascripts/identity/session_keeper.js new file mode 100644 index 0000000..55adb3f --- /dev/null +++ b/app/assets/javascripts/identity/session_keeper.js @@ -0,0 +1,51 @@ +// Shared browser-session keep-alive and recovery for ETM client apps. +// +// The shared access cookie (etm_session) is short-lived and HttpOnly, so this times off the +// non-HttpOnly etm_session_exp hint cookie instead of reading the token: it refreshes ~60s before +// expiry via MyETM and, if that succeeds, reschedules. When the hint cookie is absent on load — the +// access cookie lapsed while the 24h refresh cookie may still be valid — it attempts a single guarded +// recovery and reloads, so a returning user is re-authenticated instead of being treated as a guest. +// +// Framework-agnostic on purpose: wrap it in a Stimulus controller (importmap apps), call it from a +// bundled entrypoint, or mirror it in another stack. startSessionKeeper returns a teardown function. +const RECOVERY_KEY = "etm-session-recovery"; + +// Expiry (ms) of the shared session cookie, read from the non-HttpOnly etm_session_exp hint cookie. +const readExpiryMs = () => { + const match = document.cookie.match(/(?:^|;\s*)etm_session_exp=([^;]+)/); + const exp = match ? parseInt(decodeURIComponent(match[1]), 10) : NaN; + return Number.isFinite(exp) ? exp * 1000 : null; +}; + +// credentials:"include" so the cross-subdomain POST carries MyETM's host-only etm_refresh cookie. +const refresh = (idpUrl) => + fetch(`${idpUrl}/session/refresh`, { method: "POST", credentials: "include" }) + .then((response) => response.ok) + .catch(() => false); + +export function startSessionKeeper({ idpUrl }) { + let timer; + + const schedule = () => { + const expiryMs = readExpiryMs(); + + if (expiryMs) { + window.sessionStorage.removeItem(RECOVERY_KEY); + const delay = Math.max(expiryMs - Date.now() - 60_000, 0); + timer = window.setTimeout(() => { + refresh(idpUrl).then((ok) => ok && schedule()); + }, delay); + } else if (!window.sessionStorage.getItem(RECOVERY_KEY)) { + window.sessionStorage.setItem(RECOVERY_KEY, "1"); + refresh(idpUrl).then((ok) => { + if (ok) { + window.sessionStorage.removeItem(RECOVERY_KEY); + window.location.reload(); + } + }); + } + }; + + schedule(); + return () => window.clearTimeout(timer); +} diff --git a/app/controllers/identity/auth_controller.rb b/app/controllers/identity/auth_controller.rb index b490224..12563d9 100644 --- a/app/controllers/identity/auth_controller.rb +++ b/app/controllers/identity/auth_controller.rb @@ -3,23 +3,26 @@ module Identity # Handles OAuth2 callbacks and failures. class AuthController < ApplicationController - def sign_in; end + # Renders the sign-in button. When a visitor was redirected here only because their short access + # cookie lapsed, the layout's recovery probe silently refreshes the shared session and reloads — + # by which point they're signed in, so send them on to where they were headed instead of showing + # the button again. + def sign_in + redirect_to(return_to_path(main_app.root_path)) if signed_in? + end + # By the time this fires, the shared JWT session cookie is already set: the provider (MyETM) + # sets it during its own login step, earlier in this same top-level navigation, on the same + # parent domain. All that's left to do here is rotate the session (fixation hygiene) and return + # the visitor to where they were headed. def callback - id_session = Identity::Session.from_omniauth( - request.env['omniauth.auth']['credentials'], - request.env['omniauth.auth']['extra']['raw_info'] - ) - rotate_session - session[IDENTITY_SESSION_KEY] = id_session.dump - - Identity.config.on_sign_in&.call(id_session) - redirect_to(return_to_path(main_app.root_path)) end - def failure; end + def failure + # A real OAuth error (e.g. the user denied the request): render the failure page. + end def sign_out return redirect_to(main_app.root_path) unless signed_in? @@ -44,9 +47,13 @@ def rotate_session prev_session.each { |key, value| session[key.to_sym] = value } end + # Builds an RP-initiated logout URL. Passes the client id and a post-logout redirect def logout_url uri = URI.parse(Identity.discovery_config.end_session_endpoint) - uri.query = { access_token: identity_session.access_token.token }.to_query + uri.query = { + client_id: Identity.config.client_id, + post_logout_redirect_uri: "#{Identity.config.client_uri}/" + }.to_query uri.to_s end diff --git a/app/helpers/identity/application_helper.rb b/app/helpers/identity/application_helper.rb index 0095095..5feaeb4 100644 --- a/app/helpers/identity/application_helper.rb +++ b/app/helpers/identity/application_helper.rb @@ -2,6 +2,13 @@ module Identity module ApplicationHelper + # Data attributes wiring the shared session-keeper Stimulus controller (identity/session_keeper). + # Spread onto a persistent element (e.g. ) so a host app keeps the shared session alive + # and recovers it without duplicating the idp URL or the controller name. + def identity_session_keeper_attributes + { controller: 'session-keeper', 'session-keeper-idp-url-value': Identity.config.issuer } + end + def show_auth_back_button? referrer = request.env['HTTP_REFERER'] diff --git a/app/views/identity/auth/failure.html.erb b/app/views/identity/auth/failure.html.erb index 19d2d63..c18cf10 100644 --- a/app/views/identity/auth/failure.html.erb +++ b/app/views/identity/auth/failure.html.erb @@ -3,7 +3,7 @@

<%= t('.title') %>

<% if params[:message] == 'access_denied' %> -

<%= t('.denied') %> +

<%= t('.denied') %>

<% else %>

<%= t('.description') %>

<% end %> diff --git a/app/views/identity/auth/not_authorized.html.erb b/app/views/identity/auth/not_authorized.html.erb index 3fd2a6a..193a3aa 100644 --- a/app/views/identity/auth/not_authorized.html.erb +++ b/app/views/identity/auth/not_authorized.html.erb @@ -12,7 +12,7 @@ <%= render partial: 'identity/auth/back_button', locals: { url: :back } %> <% if signed_in? %> - <%= button_to '/auth/logout', method: :post do %> + <%= button_to sign_out_path, method: :post do %> <%= t('identity.sign_out') %> <% end%> <% else %> diff --git a/app/views/layouts/identity/application.html.erb b/app/views/layouts/identity/application.html.erb index 1886ddb..719da94 100644 --- a/app/views/layouts/identity/application.html.erb +++ b/app/views/layouts/identity/application.html.erb @@ -37,5 +37,25 @@ + <%# Silent session recovery. A visitor redirected here only because their short access cookie + lapsed still holds a valid 24h refresh cookie; one refresh via MyETM restores the shared + session and reloads, and AuthController#sign_in then forwards them on — turning "you were + bounced to sign in" into a seamless return. A genuine guest's refresh 401s and they use the + button. sessionStorage guards against reload loops (mirrors identity/session_keeper). %> + <% unless signed_in? %> + <%= javascript_tag nonce: true do %> + (function () { + var KEY = 'etm-session-recovery'; + if (window.sessionStorage.getItem(KEY)) return; + window.sessionStorage.setItem(KEY, '1'); + fetch('<%= j Identity.config.issuer %>/session/refresh', { method: 'POST', credentials: 'include' }) + .then(function (response) { + if (response.ok) { window.sessionStorage.removeItem(KEY); window.location.reload(); } + }) + .catch(function () {}); + })(); + <% end %> + <% end %> + diff --git a/identity.gemspec b/identity.gemspec index 45dd811..6bcf37a 100644 --- a/identity.gemspec +++ b/identity.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'dry-types', '~> 1.7' spec.add_dependency 'dry-validation', '>= 1.10' spec.add_dependency 'faraday', '>= 2' + spec.add_dependency 'jwt', '>= 2.5' spec.add_dependency 'omniauth', '>= 2.1' spec.add_dependency 'omniauth_openid_connect', '~> 0.4' spec.add_dependency 'omniauth-rails_csrf_protection', '~> 1.0' diff --git a/lib/identity.rb b/lib/identity.rb index 2136efd..0e271ab 100644 --- a/lib/identity.rb +++ b/lib/identity.rb @@ -31,21 +31,14 @@ module Identity # The scopes to request when authenticating. setting :scope, default: 'public' - # A proc to call after a successful sign-in. The proc will be passed the Identity::Session object. - setting :on_sign_in - - # A proc called if an Identity::InvalidGrant error is raised when attempting to refresh the user's - # access token. If no proc is specified, the session will be destroyed and the user signed out. - # The proc will receive the controller instance and exception. - setting :on_invalid_grant - # Sets whether to validate the config when mounting the Rails engine. It's useful to disabling # this when, for example, building production images where the config is not yet available. setting :validate_config, default: true - # The number of seconds before the access token expires that it should be refreshed. Set to nil - # to only refresh tokens when they have expired. - setting :refresh_token_within, default: 1.minute + # Name of the parent-domain JWT session cookie. The provider (MyETM) mints it on login and every + # ETM app reads it as the browser session: a self-contained identity JWT, verified locally by + # TokenDecoder. Auto-sent same-site to ETEngine, it unifies browser-session and API-bearer auth. + setting :session_cookie_name, default: 'etm_session' # Returns a Faraday connection to the Identity service, or the resource server. # @@ -72,13 +65,12 @@ def self.discovery_config end end -require_relative 'identity/access_token' +require_relative 'identity/errors' require_relative 'identity/config_validator' +require_relative 'identity/resource_server' +require_relative 'identity/token_decoder' require_relative 'identity/controller_helpers' require_relative 'identity/engine' -require_relative 'identity/errors' -require_relative 'identity/serializer' -require_relative 'identity/session' require_relative 'identity/user' require_relative 'identity/version' diff --git a/lib/identity/access_token.rb b/lib/identity/access_token.rb deleted file mode 100644 index b360cd0..0000000 --- a/lib/identity/access_token.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -module Identity - # Contains the access and refresh token, and expiry time. - class AccessToken - extend Dry::Initializer - - option :token, Dry::Types['strict.string'] - option :refresh_token, Dry::Types['optional.strict.string'] - option :expires_at, Dry::Types['optional.integer'] - option :created_at, Dry::Types['strict.integer'] - - class << self - def serializer - @serializer ||= Serializer.new( - token: ->(token) { token.token }, - refresh_token: ->(token) { token.refresh_token }, - expires_at: ->(token) { token.expires_at }, - created_at: ->(token) { token.created_at } - ) - end - - # Public: Loads a token from a hash representation (typically from a Rails session). - # - # Raises a SchemaMismatch error if the schema version of the hash does not match the current - # schema, or a KeyError if the hash is missing a required key. - def load(hash) - new(**serializer.loadable_hash(hash)) - rescue Dry::Types::ConstraintError => e - raise Error, e.message - end - - # Public: Creates a token from the credentials returned by OmniAuth. - def from_omniauth_credentials(credentials) - created_at = credentials['created_at'] || Time.now.to_i - - expires_at = credentials['expires_in'] ? created_at + credentials['expires_in'] : nil - - new( - token: credentials['token'], - refresh_token: credentials['refresh_token'], - expires_at: expires_at, - created_at: created_at - ) - end - end - - def http_client(**kwargs) - Identity.http_client(access_token: token, **kwargs) - end - - # Client for a resource server if configurated - def http_resource_client(**kwargs) - Identity.http_client(access_token: token, resource: true, **kwargs) - end - - # Creates a new access token using the refresh token. - def refresh - raise(Error, 'A refresh token is not available') unless refresh_token - - # Use top-level http_client as we dont want to send the expired access token. - response = Identity.http_client.post(Identity.discovery_config.token_endpoint, { - refresh_token: refresh_token, - grant_type: 'refresh_token', - client_id: Identity.config.client_id, - client_secret: Identity.config.client_secret - }) - - self.class.new( - token: response.body['access_token'], - refresh_token: response.body['refresh_token'], - token_type: response.body['token_type'], - expires_at: response.body['created_at'] + response.body['expires_in'], - created_at: response.body['created_at'] - ) - rescue Faraday::Error => e - raise Error.from_faraday(e) - end - - # Returns if the access token has expired and needs to be refreshed. - def expired? - expires? && expires_at < Time.now.to_i - end - - # Returns if the access token has expired, or will expire in the next 60 seconds. - def expires_soon? - return false unless expires? - return false unless Identity.config.refresh_token_within - - Time.at(expires_at) < Time.now + Identity.config.refresh_token_within.seconds - end - - # Returns if the token ever expires. - def expires? - !expires_at.nil? - end - - def ==(other) - other.is_a?(self.class) && other.token == token - end - - # Public: Returns a hash representation of the token for serialization in the Rails session. - def dump - self.class.serializer.dump(self) - end - end -end diff --git a/lib/identity/config_validator.rb b/lib/identity/config_validator.rb index 3c1af96..bc2b49c 100644 --- a/lib/identity/config_validator.rb +++ b/lib/identity/config_validator.rb @@ -13,7 +13,6 @@ def self.validate!(config) required(:client_id).filled(:string) required(:client_secret).filled(:string) required(:client_uri).filled(:string) - optional(:refresh_token_within).filled(:integer).value(gteq?: 0) optional(:resource_uri).maybe(:string) end diff --git a/lib/identity/controller_helpers.rb b/lib/identity/controller_helpers.rb index 630d08c..128d7f8 100644 --- a/lib/identity/controller_helpers.rb +++ b/lib/identity/controller_helpers.rb @@ -7,15 +7,12 @@ module ControllerHelpers included do helper_method :identity_user - helper_method :identity_access_token helper_method :signed_in? helper_method :sign_up_url helper_method :user_profile_url end - IDENTITY_SESSION_KEY = 'identity.session' - # Rendering helpers # ----------------- @@ -56,15 +53,13 @@ def authenticate_admin!(show_as: :not_found) # ----- def signed_in? - identity_session.present? + identity_token.present? end def identity_user - identity_session&.user - end + return @identity_user if defined?(@identity_user) - def identity_access_token - identity_session&.access_token + @identity_user = identity_token && Identity::User.from_jwt_claims(identity_token) end # Routes @@ -87,33 +82,23 @@ def sign_up_url private - def identity_session - return nil unless identity_session_attributes.present? - return @identity_session if @identity_session - - id_session, refreshed = Identity::Session.load_fresh(identity_session_attributes) - session[IDENTITY_SESSION_KEY] = id_session.dump if refreshed + # The verified claims of the JWT session cookie, or nil when it is absent or invalid. This is + # the shared domain cookie that acts as the browser session: a missing or expired cookie simply + # means "signed out", so the user re-authenticates (or the session is refreshed) rather than + # seeing a 401 (contrast with Identity::ResourceServer, which raises for API requests). + def identity_token + return @identity_token if defined?(@identity_token) - @identity_session = id_session - rescue Identity::InvalidGrant => e - Rails.logger.error(e.message) - - if Identity.config.on_invalid_grant - Identity.config.on_invalid_grant.call(self, e) - else - reset_session - nil - end - rescue StandardError => e - Rails.logger.error(e.message) - Sentry.capture_exception(e) if defined?(Sentry) - - reset_session + @identity_token = session_cookie_token && Identity::TokenDecoder.decode(session_cookie_token) + rescue Identity::TokenDecoder::DecodeError + @identity_token = nil + end - # A schema mismatch may occur if we change how we serialize data, and an issuer mismatch will - # happen when running locally and changing which ETEngine is used. Both are recoverable by - # signing the user out. - raise e unless e.is_a?(Identity::SchemaMismatch) || e.is_a?(Identity::IssuerMismatch) + # The raw JWT from the shared session cookie, or nil. Uses request.cookies (not the `cookies` + # helper) so the same extraction works from Identity::ResourceServer in API controllers too; + # it's the single place either concern reads the cookie. + def session_cookie_token + request.cookies[Identity.config.session_cookie_name].presence end # Remembers the current path so that the user can be redirected back to it after signing in. @@ -128,10 +113,5 @@ def remember_return_to_path def return_to_path(fallback) session.delete(:return_to) || fallback end - - # Returns the attributes stored in the session for authentication. - def identity_session_attributes - session[IDENTITY_SESSION_KEY] - end end end diff --git a/lib/identity/engine.rb b/lib/identity/engine.rb index 16d4112..0be2a74 100644 --- a/lib/identity/engine.rb +++ b/lib/identity/engine.rb @@ -15,7 +15,8 @@ class Engine < ::Rails::Engine name: 'identity', discovery: true, issuer: Identity.config.issuer, - response_code: :code, + response_type: :code, + allow_authorize_params: %i[id_token_hint login_hint], scope: Identity.config.scope, client_options: { port: issuer.port, diff --git a/lib/identity/errors.rb b/lib/identity/errors.rb index 5a91b09..836d846 100644 --- a/lib/identity/errors.rb +++ b/lib/identity/errors.rb @@ -2,44 +2,7 @@ module Identity # General class for all errors in Identity. - class Error < RuntimeError - # rubocop:disable Naming/MethodParameterName - def self.from_faraday(e) - type, message = - if e.response && e.response[:body].is_a?(Hash) - [ - e.response[:body]['error'] == 'invalid_grant' ? InvalidGrant : Error, - e.response[:body]['error_description'] - ] - else - [self, e.message] - end - - raise(type, "Failed to refresh token: #{message}") - end - # rubocop:enable Naming/MethodParameterName - end - - # Raised when refreshing a token fails due to the grant being revoked. - class InvalidGrant < Error; end - - # Raised when loading user data from the session fails because the schema version of the user - # data does not match the current schema version. - class SchemaMismatch < Error - def initialize(expected, actual) - expected = expected.to_a.sort.join(', ') - actual = actual.to_a.sort.join(', ') - super("Schema version mismatch: expected {#{expected}}, got {#{actual}}") - end - end - - # Raised when deserializing a session fails due to the configured issuer not matching the - # one used to serialize the session. - class IssuerMismatch < Error - def initialize(expected, actual) - super("Issuer mismatch: expected #{expected.inspect}, got #{actual.inspect}") - end - end + class Error < RuntimeError; end # Raises when trying to initialize Identity with an invalid or incomplete configuration. class InvalidConfig < Error diff --git a/lib/identity/resource_server.rb b/lib/identity/resource_server.rb new file mode 100644 index 0000000..12e4ba8 --- /dev/null +++ b/lib/identity/resource_server.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Identity + # Controller concern for resource servers (APIs that accept bearer JWTs). Owns the bearer-token + # plumbing and verification; the including controller owns current_user and ability. + # + # Extract a token from the Authorization header (or an `access_token` param), verify it via + # Identity::TokenDecoder, and memoise the decoded claims as #decoded_token. + module ResourceServer + extend ActiveSupport::Concern + + included do + rescue_from Identity::TokenDecoder::DecodeError, with: :render_invalid_token + end + + private + + # Returns the decoded/verified token claims, or nil if no token was provided. + def decoded_token + return @decoded_token if defined?(@decoded_token) + + @decoded_token = bearer_token && Identity::TokenDecoder.decode(bearer_token) + end + + # The raw token from, in order: the Authorization header, the access_token param, or the shared + # JWT session cookie (via Identity::ControllerHelpers#session_cookie_token, the single place the + # cookie is read). Reading the cookie here is the unification seam: a browser request carrying + # the domain cookie authenticates through the exact same path as an API bearer request. + def bearer_token + request.authorization.to_s[/\ABearer (.+)\z/, 1] || + params.permit(:access_token)[:access_token].presence || + session_cookie_token + end + + def render_invalid_token + render json: { errors: ['Invalid or expired token'] }, status: :unauthorized + end + end +end diff --git a/lib/identity/serializer.rb b/lib/identity/serializer.rb deleted file mode 100644 index 1bb6b13..0000000 --- a/lib/identity/serializer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Identity - # Serializes sessions and users, enforcing the schema. - class Serializer - def initialize(**schema) - @schema = schema - @keys = Set.new(schema.keys.map(&:to_sym)) - end - - # Given a hash, return a hash that only contains the keys that are used by this serializer. - # - # Raises an error if the schema version is not the same as the current schema version. - def loadable_hash(hash) - hash = hash.transform_keys(&:to_sym) - given_keys = Set.new(hash.keys) - - raise SchemaMismatch.new(@keys, given_keys) if @keys != given_keys - - hash.slice(*@schema.keys) - end - - # Given an object, return a hash that serializes the object with the schema version. - def dump(object) - @schema.transform_values { |mapping| mapping.call(object) } - end - end -end diff --git a/lib/identity/session.rb b/lib/identity/session.rb deleted file mode 100644 index c91876c..0000000 --- a/lib/identity/session.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module Identity - # Contains the current user and access token. - class Session - extend Dry::Initializer - - option :user - option :access_token - - delegate :expired?, :expires_soon?, to: :access_token - - class << self - def serializer - @serializer ||= Serializer.new( - access_token: ->(session) { session.access_token.dump }, - user: ->(session) { session.user.dump }, - issuer: ->(*) { Identity.config.issuer } - ) - end - - def from_omniauth(credentials, hash) - new( - user: User.from_omniauth_hash(hash), - access_token: AccessToken.from_omniauth_credentials(credentials) - ) - end - - def load(hash) - hash = serializer.loadable_hash(hash) - - if Identity.config.issuer != hash[:issuer] - raise IssuerMismatch.new(Identity.config.issuer, hash[:issuer]) - end - - new( - user: User.load(hash[:user]), - access_token: AccessToken.load(hash[:access_token]) - ) - end - - # Loads the session and refreshes the access token if it is about to expire. - # - # @return [Array] - # a tuple containing the sessiona nd a boolean indicating whether the session was refreshed - def load_fresh(hash) - session = load(hash) - session.expires_soon? ? [session.refresh, true] : [session, false] - end - end - - def dump - self.class.serializer.dump(self) - end - - # Creates a new session with a refreshed token. Also fetches a fresh copy of the user data in - # case anything has changed. - # - # @return [Session] a new session with the refreshed token - def refresh - new_token = @access_token.refresh - - user_data = Identity - .http_client(access_token: new_token.token) - .get(Identity.discovery_config.userinfo_endpoint) - .body - - self.class.new(user: User.from_omniauth_hash(user_data), access_token: new_token) - end - end -end diff --git a/lib/identity/test/controller_helpers.rb b/lib/identity/test/controller_helpers.rb index aa263a6..ff62183 100644 --- a/lib/identity/test/controller_helpers.rb +++ b/lib/identity/test/controller_helpers.rb @@ -8,15 +8,22 @@ def self.included(*) raise 'Identity::Test::ControllerHelpers can only be used with RSpec' unless defined?(RSpec) end + # Stubs the controller as though the given user is already signed in via the shared JWT + # session cookie (identity_user is the only thing derived from it), without needing a real + # signed token or a round-trip to the provider. Sets both the top-level `roles` claim and the + # `user.admin` flag, since consuming apps read the admin bit either way (Identity::User checks + # `roles` first, falling back to `user.admin`; host apps' own User.from_jwt!-style methods + # typically only check `user.admin`). def sign_in(user) - allow(controller).to receive(:identity_session_attributes).and_return( - issuer: Identity.config.issuer, - user: user.identity_user.dump, - access_token: { - token: "access_#{SecureRandom.base58}", - refresh_token: "refresh_#{SecureRandom.base58}", - created_at: Time.now.to_i, - expires_at: nil + identity_user = user.identity_user + + allow(controller).to receive(:identity_token).and_return( + 'sub' => identity_user.id, + 'roles' => identity_user.roles.to_a, + 'user' => { + 'email' => identity_user.email, + 'name' => identity_user.name, + 'admin' => identity_user.admin? } ) end diff --git a/lib/identity/test/system_helpers.rb b/lib/identity/test/system_helpers.rb index 46e5e02..a7fc6f9 100644 --- a/lib/identity/test/system_helpers.rb +++ b/lib/identity/test/system_helpers.rb @@ -4,6 +4,9 @@ module Identity module Test # Provides helpers for system specs. module SystemHelpers + TEST_SIGNING_KEY = OpenSSL::PKey::RSA.new(2048) + TEST_KID = 'identity-test' + # Public: Instructs OmniAuth to provide a fake authentication response where the user is a # user. def mock_omniauth_user_sign_in(**kwargs) @@ -16,9 +19,13 @@ def mock_omniauth_admin_sign_in(**kwargs) mock_omniauth_sign_in(roles: %w[user admin], **kwargs) end - # Public: Instructs OmniAuth to provide a fake authentication response to sign in a user. + # Public: Simulates a user who already holds a valid shared JWT session cookie, as if MyETM + # had already signed them in during an earlier step of the same top-level navigation. There is + # no separate provider app in this test harness to set the cookie mid-navigation, so it is + # applied as a side effect of the real sign-in callback (still exercising the real + # rotate_session/redirect logic) rather than requiring a network round trip to a real provider. # - # Returns the AccessToken which represents the user's session. + # Returns the raw signed JWT. def mock_omniauth_sign_in( id: SecureRandom.random_number(1e10.to_i), name: 'John Doe', @@ -29,38 +36,38 @@ def mock_omniauth_sign_in( OmniAuth.config.test_mode = true OmniAuth.config.logger = Rails.logger - token = "test_access_#{SecureRandom.base58(16)}" - refresh_token = "test_refresh_#{SecureRandom.base58(16)}" + OmniAuth.config.mock_auth[:identity] = OmniAuth::AuthHash.new('provider' => 'identity', 'uid' => id) - OmniAuth.config.mock_auth[:identity] = OmniAuth::AuthHash.new( - 'provider' => 'identity', - 'uid' => id, - 'info' => { - 'email' => email, - 'nickname' => nil - }, - 'credentials' => { - 'token' => token, - 'refresh_token' => refresh_token, - 'expires_at' => expires_at.to_i, - 'expires' => true - }, - 'extra' => { - 'raw_info' => { - 'sub' => id, - 'email' => email, - 'roles' => roles, - 'name' => name - } - } - ) + token = sign_test_jwt(id: id, name: name, email: email, roles: roles, expires_at: expires_at) - Identity::AccessToken.new( - token: token, - refresh_token: refresh_token, - expires_at: expires_at.to_i, - created_at: Time.now.to_i + allow(Identity::TokenDecoder).to receive(:jwk_set).and_return( + 'keys' => [JWT::JWK.new(TEST_SIGNING_KEY.public_key, TEST_KID).export] ) + + allow_any_instance_of(Identity::AuthController).to receive(:callback) do |controller| + controller.instance_exec do + rotate_session + cookies[Identity.config.session_cookie_name] = token + redirect_to(return_to_path(main_app.root_path)) + end + end + + token + end + + private + + def sign_test_jwt(id:, name:, email:, roles:, expires_at:) + payload = { + iss: Identity.config.issuer, + aud: Identity.config.client_uri, + sub: id.to_s, + exp: expires_at.to_i, + roles: roles, + user: { email: email, name: name } + } + + JWT.encode(payload, TEST_SIGNING_KEY, 'RS256', kid: TEST_KID) end end end diff --git a/lib/identity/token_decoder.rb b/lib/identity/token_decoder.rb new file mode 100644 index 0000000..3c13500 --- /dev/null +++ b/lib/identity/token_decoder.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'jwt' + +module Identity + # Resource-server JWT verification. Decodes and verifies bearer tokens issued by the Identity + # provider, validating the signature against the provider's JWKS and the standard claims. + # + # This is the single implementation shared by all resource servers (ETEngine, ETModel); each app + # previously kept its own near-identical copy. + module TokenDecoder + module_function + + DecodeError = Class.new(Identity::Error) + + JWK_CACHE_KEY = 'identity.jwk_set' + + # Decodes and verifies a JWT, returning the decoded claims with indifferent access (consumers + # across ETEngine/ETModel read claims via both string and symbol keys). + # + # Raises DecodeError if the signature or any claim is invalid. + def decode(raw) + payload, = JWT.decode(strip_etm_prefix(raw), nil, true, decode_options) + + # required_claims only checks that the key exists, not that it's non-blank; a `sub: null` + # claim would otherwise pass. + raise JWT::DecodeError, 'Missing sub claim' if payload['sub'].blank? + + payload.with_indifferent_access + rescue JWT::DecodeError => e + raise DecodeError, e.message + end + + # `aud` may be a single URI or a JSON array of URIs: the shared session cookie targets several + # in-scope apps at once. JWT::Claims::Audience already checks membership (not equality) when + # either side is an array, so no hand-rolled normalisation is needed here. + def decode_options + { + algorithms: ['RS256'], + jwks: jwk_loader, + verify_iss: true, + iss: Identity.config.issuer, + verify_aud: true, + aud: Identity.config.client_uri, + verify_expiration: true, + required_claims: %w[sub exp] + } + end + + # Resolves the signing key for a token by `kid`, fetching (and caching) the provider's JWKS. + # The `jwt` gem retries once with `invalidate: true` when the kid isn't found in the cached set + # (the provider may have rotated its keys), which covers key rotation with no hand-rolled retry + # logic here. + def jwk_loader + ->(options) { jwk_set(invalidate: options[:invalidate]) } + end + + # Fetches (and caches for 24h) the provider's full JWKS document. Using the whole set rather + # than a single key keeps verification working across key rotation. + def jwk_set(invalidate: false) + jwk_cache.delete(JWK_CACHE_KEY) if invalidate + + jwk_cache.fetch(JWK_CACHE_KEY, expires_in: 24.hours) { jwks_client.get.body } + end + + def jwks_client + Faraday.new(Identity.discovery_config.jwks_uri) do |conn| + conn.request(:json) + conn.response(:json) + conn.response(:raise_error) + end + end + + # Strips the ETM personal-access-token prefixes before verification. + def strip_etm_prefix(token) + token.sub(/^etm_(beta_)?/, '') + end + + def jwk_cache + @jwk_cache ||= + if defined?(Rails) && Rails.env.development? + ActiveSupport::Cache::MemoryStore.new + else + Rails.cache + end + end + end +end diff --git a/lib/identity/user.rb b/lib/identity/user.rb index 4121518..c620b0c 100644 --- a/lib/identity/user.rb +++ b/lib/identity/user.rb @@ -11,31 +11,18 @@ class User option :name, Dry::Types['optional.strict.string'] class << self - def serializer - @serializer ||= Serializer.new( - id: ->(user) { user.id }, - roles: ->(user) { user.roles.to_a }, - email: ->(user) { user.email }, - name: ->(user) { user.name } - ) - end + # Public: Creates a user from the verified claims of the shared JWT session cookie. Roles + # travel as a top-level claim when present; otherwise fall back to the boolean admin flag + # carried in the `user` claim. + def from_jwt_claims(claims) + user = claims['user'] || {} - # Public: Creates a user from an OmniAuth::AuthHash. - def from_omniauth_hash(hash) new( - id: hash['sub'], - roles: hash.fetch('roles', []).to_a, - email: hash['email'], - name: hash['name'] + id: claims['sub'], + roles: claims['roles'] || (user['admin'] ? ['admin'] : []), + email: user['email'], + name: user['name'] ) - end - - # Public: Loads a user from a hash representation (typically from a Rails session). - # - # Raises a SchemaMismatch error if the schema version of the hash does not match the current - # schema, or a KeyError if the hash is missing a required key. - def load(hash) - new(**serializer.loadable_hash(hash)) rescue Dry::Types::ConstraintError => e raise Error, e.message end @@ -45,11 +32,6 @@ def admin? roles.include?('admin') end - # Public: Returns a hash representation of the user for serialization in the Rails session. - def dump - self.class.serializer.dump(self) - end - # Public: Returns if the user is equal to the other object. This is the case if the other object # is also a user with the same ID. def ==(other) diff --git a/spec/dummy/app/controllers/provider_controller.rb b/spec/dummy/app/controllers/provider_controller.rb index dd5c400..74ec2cb 100644 --- a/spec/dummy/app/controllers/provider_controller.rb +++ b/spec/dummy/app/controllers/provider_controller.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true -# A controller which stubs some OAuth2 provider endpoints. +# A controller which stubs some OAuth2 provider endpoints. In production, the provider (MyETM) +# clears the shared session cookie during its own logout; this mimics that so signing out behaves +# the same way in tests. class ProviderController < ApplicationController def logout + cookies.delete(Identity.config.session_cookie_name) redirect_to '/', allow_other_host: true end end diff --git a/spec/identity/access_token_spec.rb b/spec/identity/access_token_spec.rb deleted file mode 100644 index 46ecc9a..0000000 --- a/spec/identity/access_token_spec.rb +++ /dev/null @@ -1,294 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Identity::AccessToken do - let(:token_attributes) do - { token: 'abc', refresh_token: 'def', expires_at: 456, created_at: 123 } - end - - describe '.load' do - context 'when given a token, refresh, and expires_at' do - let(:token) do - described_class.load(**token_attributes) - end - - it 'sets the token' do - expect(token.token).to eq('abc') - end - - it 'sets the refresh token' do - expect(token.refresh_token).to eq('def') - end - - it 'sets the created_at' do - expect(token.created_at).to eq(123) - end - - it 'sets the expires_at' do - expect(token.expires_at).to eq(456) - end - end - - context 'when given refresh and expires_at of nil' do - let(:token) do - described_class.load(**token_attributes.merge(refresh_token: nil, expires_at: nil)) - end - - it 'sets the refresh token to be nil' do - expect(token.refresh_token).to be_nil - end - - it 'sets the expires_at to be nil' do - expect(token.expires_at).to be_nil - end - end - end - - context 'when the expiry is in 10 seconds' do - let(:token) do - described_class.new(**token_attributes.merge(expires_at: Time.now.to_i + 10)) - end - - it 'returns false' do - expect(token).not_to be_expired - end - - it 'expires' do - expect(token.expires?).to be(true) - end - - it 'will expire soon' do - expect(token.expires_soon?).to be(true) - end - end - - context 'when the expiry is in 10 seconds and refresh_token_within is nil' do - let(:token) do - described_class.new(**token_attributes.merge(expires_at: Time.now.to_i + 10)) - end - - before { Identity.config.refresh_token_within = nil } - after { Identity.config.refresh_token_within = 1.minute } - - it 'returns false' do - expect(token).not_to be_expired - end - - it 'expires' do - expect(token.expires?).to be(true) - end - - it 'will not expire soon' do - expect(token.expires_soon?).to be(false) - end - end - - context 'when the expiry is in 90 seconds' do - let(:token) do - described_class.new(**token_attributes.merge(expires_at: Time.now.to_i + 90)) - end - - it 'returns false' do - expect(token).not_to be_expired - end - - it 'expires' do - expect(token.expires?).to be(true) - end - - it 'will not expire soon' do - expect(token.expires_soon?).to be(false) - end - end - - context 'when the expiry is in the past' do - let(:token) do - described_class.new(**token_attributes.merge(expires_at: Time.now.to_i - 10)) - end - - it 'returns true' do - expect(token).to be_expired - end - - it 'expires' do - expect(token.expires?).to be(true) - end - - it 'will expire soon' do - expect(token.expires_soon?).to be(true) - end - end - - context 'when no expiry is set' do - let(:token) do - described_class.new(**token_attributes.merge(expires_at: nil)) - end - - it 'is expired' do - expect(token).not_to be_expired - end - - it 'does not expire' do - expect(token.expires?).to be(false) - end - - it 'does not expire soon' do - expect(token.expires_soon?).to be(false) - end - end - - describe '.from_omniauth_credentials' do - let(:token) do - described_class.from_omniauth_credentials({ - 'token' => '__access_token__', - 'refresh_token' => '__refresh_token__', - 'expires_in' => 123 - }) - end - - it 'sets the token' do - expect(token.token).to eq('__access_token__') - end - - it 'sets the refresh_token' do - expect(token.refresh_token).to eq('__refresh_token__') - end - - it 'sets the expires_at' do - expect(token.expires_at).to be_within(2).of(Time.now.to_i + 123) - end - - it 'sets the created_at' do - expect(token.created_at).to be_within(2).of(Time.now.to_i) - end - end - - context 'when creating an HTTP client' do - it 'creates a client with the token' do - allow(Identity).to receive(:http_client) - described_class.new(**token_attributes.merge(refresh_token: nil)).http_client - - expect(Identity).to have_received(:http_client).with(access_token: 'abc') - end - end - - context 'when refreshing the token' do - context 'when no refresh token is set' do - let(:token) { described_class.new(**token_attributes.merge(refresh_token: nil)) } - - it 'raises an error' do - expect { token.refresh }.to raise_error(Identity::Error, 'A refresh token is not available') - end - end - - context 'when a token is set' do - let(:token) { described_class.new(**token_attributes) } - let(:now) { Time.now.to_i } - - before do - conn = Faraday.new do |builder| - builder.request(:json) - builder.response(:json) - - builder.adapter(:test) do |stub| - stub.post('oauth/token') do |_env| - [ - 200, - { 'Content-Type': 'application/json; charset=utf-8' }, - { - 'access_token' => 'hij', - 'token_type' => 'Bearer', - 'expires_in' => 7200, - 'refresh_token' => 'klm', - 'scope' => 'openid profile email scenarios:read', - 'created_at' => now, - 'id_token' => '' - }.to_json - ] - end - end - end - - allow(Identity).to receive(:http_client).and_return(conn) - end - - it 'returns an access token' do - expect(token.refresh).to be_a(described_class) - end - - it 'does not return the same object' do - expect(token.refresh.__id__).not_to be(token.__id__) - end - - it 'sets the new token' do - expect(token.refresh.token).to eq('hij') - end - - it 'sets the refresh token' do - expect(token.refresh.refresh_token).to eq('klm') - end - - it 'sets the expires_at' do - expect(token.refresh.expires_at).to eq(now + 7200) - end - end - - context 'when the refresh endpoint returns a client error' do - let(:token) { described_class.new(**token_attributes) } - - before do - response = { - body: { - 'error' => 'invalid_grant', - 'error_description' => - 'The provided authorization grant is invalid, expired, revoked, does not ' \ - 'match the redirection URI used in the authorization request, or was issued ' \ - 'to another client.' - } - } - - conn = Faraday.new do |builder| - builder.request(:json) - builder.response(:json) - - builder.adapter(:test) do |stub| - stub.post('oauth/token') do |_env| - raise Faraday::BadRequestError.new(nil, response) - end - end - end - - allow(Identity).to receive(:http_client).and_return(conn) - end - - it 'raises an error' do - expect { token.refresh }.to raise_error( - Identity::Error, - /Failed to refresh token: The provided authorization grant is invalid/ - ) - end - end - - context 'when the refresh endpoint returns a server error' do - let(:token) { described_class.new(**token_attributes) } - - before do - conn = Faraday.new do |builder| - builder.request(:json) - builder.response(:json) - - builder.adapter(:test) do |stub| - stub.post('oauth/token') do |_env| - raise Faraday::Error, 'some message' - end - end - end - - allow(Identity).to receive(:http_client).and_return(conn) - end - - it 'raises an error' do - expect { token.refresh }.to raise_error(Identity::Error, /some message/) - end - end - end -end diff --git a/spec/identity/application_helper_spec.rb b/spec/identity/application_helper_spec.rb new file mode 100644 index 0000000..9382706 --- /dev/null +++ b/spec/identity/application_helper_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe Identity::ApplicationHelper, type: :helper do + describe '#identity_session_keeper_attributes' do + it 'wires the session-keeper controller to the configured idp URL' do + expect(helper.identity_session_keeper_attributes).to eq( + controller: 'session-keeper', + 'session-keeper-idp-url-value': Identity.config.issuer + ) + end + end +end diff --git a/spec/identity/config_validator_spec.rb b/spec/identity/config_validator_spec.rb index 55c84a0..2aef334 100644 --- a/spec/identity/config_validator_spec.rb +++ b/spec/identity/config_validator_spec.rb @@ -8,8 +8,7 @@ issuer: 'https://issuer', client_id: 'client_id_123', client_secret: 'client_secret_123', - client_uri: 'https://client', - refresh_token_within: 60 + client_uri: 'https://client' } end @@ -128,35 +127,5 @@ expect { described_class.validate!(config.to_h) } .to raise_error(Identity::InvalidConfig, /- issuer must be filled/) end - - it 'raises an error when the refresh_token_within is nil' do - config = valid_config.merge(refresh_token_within: nil) - - expect { described_class.validate!(config.to_h) } - .to raise_error(Identity::InvalidConfig, /- refresh_token_within must be filled/) - end - - it 'raises an error when the refresh_token_within a negative number' do - config = valid_config.merge(refresh_token_within: -1) - - expect { described_class.validate!(config.to_h) }.to raise_error( - Identity::InvalidConfig, - /- refresh_token_within must be greater than or equal to 0/ - ) - end - - it 'raises no error when the refresh_token_within is a positive duration' do - config = valid_config.merge(refresh_token_within: 1.minute) - expect { described_class.validate!(config.to_h) }.not_to raise_error - end - - it 'raises an error when the refresh_token_within is a negative duration' do - config = valid_config.merge(refresh_token_within: -1.minute) - - expect { described_class.validate!(config.to_h) }.to raise_error( - Identity::InvalidConfig, - /- refresh_token_within must be greater than or equal to 0/ - ) - end end end diff --git a/spec/identity/controller_helpers_spec.rb b/spec/identity/controller_helpers_spec.rb index 03b3cdf..9f1e052 100644 --- a/spec/identity/controller_helpers_spec.rb +++ b/spec/identity/controller_helpers_spec.rb @@ -1,89 +1,77 @@ # frozen_string_literal: true RSpec.describe Identity::ControllerHelpers do - context 'when the serialized session is invalid' do + describe 'JWT session cookie identity' do let(:controller) do Class.new do def self.helper_method(*); end - include Identity::ControllerHelpers - def identity_session_attributes - { 'invalid' => true } - end - - def reset_session; end - end.new - end - - it 'resets the session' do - allow(controller).to receive(:reset_session) - - controller.send(:identity_session) - expect(controller).to have_received(:reset_session) - end - end - - context 'when the refreshing the token fails' do - let(:controller) do - Class.new do - def self.helper_method(*); end + attr_reader :request, :session - include Identity::ControllerHelpers - - def identity_session_attributes - { 'invalid' => true } + define_method(:initialize) do + @request = Struct.new(:cookies).new({}) + @session = {} end - - def reset_session; end end.new end - before do - allow(Identity::Session).to receive(:load_fresh).and_raise(Identity::InvalidGrant) - end - - it 'resets the session' do - allow(controller).to receive(:reset_session) - - controller.send(:identity_session) - expect(controller).to have_received(:reset_session) + let(:claims) do + { 'sub' => '42', 'user' => { 'email' => 'a@b.c', 'name' => 'Ada', 'admin' => true } } end - end - context 'when an unexpected error occurs' do - let(:controller) do - Class.new do - def self.helper_method(*); end - - include Identity::ControllerHelpers + context 'with a valid cookie' do + before do + controller.request.cookies[Identity.config.session_cookie_name] = 'raw.jwt' + allow(Identity::TokenDecoder).to receive(:decode).and_return(claims) + end - def identity_session_attributes - { 'invalid' => true } - end + it 'is signed in' do + expect(controller.signed_in?).to be(true) + end - def reset_session; end - end.new + it 'builds the user from the claims' do + user = controller.identity_user + expect(user.id).to eq('42') + expect(user.email).to eq('a@b.c') + expect(user.name).to eq('Ada') + expect(user.admin?).to be(true) + end end - before do - allow(Identity::Session).to receive(:load_fresh).and_raise(Identity::Error, 'oops') + context 'with a top-level roles claim' do + before do + controller.request.cookies[Identity.config.session_cookie_name] = 'raw.jwt' + allow(Identity::TokenDecoder).to receive(:decode).and_return( + claims.merge('roles' => %w[admin researcher]) + ) + end + + it 'uses the roles claim' do + expect(controller.identity_user.roles).to include('researcher', 'admin') + end end - it 'resets the session' do - allow(controller).to receive(:reset_session) + context 'with an invalid cookie' do + before do + controller.request.cookies[Identity.config.session_cookie_name] = 'bad' + allow(Identity::TokenDecoder).to receive(:decode) + .and_raise(Identity::TokenDecoder::DecodeError) + end - begin - controller.send(:identity_session) - rescue Identity::Error - # Do nothing. + it 'is not signed in' do + expect(controller.signed_in?).to be(false) end - expect(controller).to have_received(:reset_session) + it 'has no identity user' do + expect(controller.identity_user).to be_nil + end end - it 'raises the error' do - expect { controller.send(:identity_session) }.to raise_error(Identity::Error, 'oops') + context 'with no cookie' do + it 'is not signed in' do + expect(controller.signed_in?).to be(false) + end end end end diff --git a/spec/identity/resource_server_spec.rb b/spec/identity/resource_server_spec.rb new file mode 100644 index 0000000..3b29347 --- /dev/null +++ b/spec/identity/resource_server_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +RSpec.describe Identity::ResourceServer do + let(:controller) do + Class.new do + def self.rescue_from(*); end + def self.helper_method(*); end + + # ControllerHelpers provides session_cookie_token, the single place the cookie is read; every + # real controller includes both concerns together (the engine mixes in ControllerHelpers + # globally, ResourceServer is opt-in on top for API controllers). + include Identity::ControllerHelpers + include Identity::ResourceServer + + attr_writer :request, :params + + def request = @request + def params = @params + def cookies = {} + def session = {} + end.new + end + + def request_double(authorization: nil, cookies: {}) + double('request', authorization: authorization, cookies: cookies) + end + + describe '#bearer_token' do + it 'reads the Authorization header first' do + controller.request = request_double( + authorization: 'Bearer header-token', + cookies: { Identity.config.session_cookie_name => 'cookie-token' } + ) + controller.params = ActionController::Parameters.new(access_token: 'param-token') + + expect(controller.send(:bearer_token)).to eq('header-token') + end + + it 'falls back to the access_token param' do + controller.request = request_double(cookies: {}) + controller.params = ActionController::Parameters.new(access_token: 'param-token') + + expect(controller.send(:bearer_token)).to eq('param-token') + end + + it 'falls back to the JWT session cookie' do + controller.request = request_double( + cookies: { Identity.config.session_cookie_name => 'cookie-token' } + ) + controller.params = ActionController::Parameters.new + + expect(controller.send(:bearer_token)).to eq('cookie-token') + end + + it 'is nil when no token is present anywhere' do + controller.request = request_double(cookies: {}) + controller.params = ActionController::Parameters.new + + expect(controller.send(:bearer_token)).to be_nil + end + end +end diff --git a/spec/identity/serializer_spec.rb b/spec/identity/serializer_spec.rb deleted file mode 100644 index cd2e318..0000000 --- a/spec/identity/serializer_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Identity::Serializer do - let(:serializer) do - described_class.new( - name: ->(user) { user.name }, - email: ->(user) { user.email } - ) - end - - let(:user) do - Struct.new(:name, :email).new('John Doe', 'person@example.org') - end - - context 'when dumping to a hash' do - let(:dumped) { serializer.dump(user) } - - it 'dumps the user with the schema' do - expect(dumped.except(:schema_version)).to eq( - name: 'John Doe', - email: 'person@example.org' - ) - end - end - - context 'when loading a hash' do - it 'loads a hash with valid keys' do - dumped = serializer.dump(user) - - expect(serializer.loadable_hash(dumped)).to eq( - name: 'John Doe', - email: 'person@example.org' - ) - end - - it 'raises an error when the hash is missing a key' do - dumped = serializer.dump(user) - invalid = dumped.except(:name) - - expect { serializer.loadable_hash(invalid) }.to raise_error( - Identity::SchemaMismatch, - /expected {email, name}, got {email}/ - ) - end - - it 'raises an error when the hash has an extra key' do - dumped = serializer.dump(user) - invalid = dumped.merge(extra: 'value') - - expect { serializer.loadable_hash(invalid) }.to raise_error( - Identity::SchemaMismatch, - /expected {email, name}, got {email, extra, name}/ - ) - end - end -end diff --git a/spec/identity/session_spec.rb b/spec/identity/session_spec.rb deleted file mode 100644 index 6c4238f..0000000 --- a/spec/identity/session_spec.rb +++ /dev/null @@ -1,269 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Identity::Session do - describe '.from_omniauth' do - let(:session) do - described_class.from_omniauth( - { - 'token' => '__access_token__', - 'refresh_token' => '__refresh_token__', - 'expires_at' => (Time.now + 3600).to_i - }, - { - 'sub' => '123', - 'email' => 'hello@example.org', - 'name' => 'John Doe', - 'roles' => %w[admin] - } - ) - end - - it 'sets the user' do - expect(session.user.dump).to eq( - id: '123', - email: 'hello@example.org', - name: 'John Doe', - roles: %w[admin] - ) - end - - it 'sets the access token' do - expect(session.access_token.token).to eq('__access_token__') - end - end - - describe '.load' do - let(:user_attributes) do - { - 'id' => '123', - 'roles' => %w[admin], - 'email' => 'hello@example.org', - 'name' => 'John Doe' - } - end - - let(:token_attributes) do - { - 'token' => '__access_token__', - 'refresh_token' => '__refresh_token__', - 'created_at' => Time.now.to_i, - 'expires_at' => (Time.now + 3600).to_i - } - end - - context 'with valid attributes' do - let(:session) do - described_class.load( - user: user_attributes, - access_token: token_attributes, - issuer: Identity.config.issuer - ) - end - - it 'sets the user' do - expect(session.user.dump.transform_keys(&:to_s)).to eq(user_attributes) - end - - it 'sets the token' do - expect(session.access_token.dump.transform_keys(&:to_s)).to eq(token_attributes) - end - end - - context 'with a missing user' do - it 'raises an error' do - expect do - described_class.load( - access_token: token_attributes, - issuer: Identity.config.issuer - ) - end - .to raise_error( - Identity::SchemaMismatch, - /expected {access_token, issuer, user}, got {access_token, issuer}/ - ) - end - end - - context 'with a missing token' do - it 'raises an error' do - expect do - described_class.load(user: user_attributes, issuer: Identity.config.issuer) - end - .to raise_error( - Identity::SchemaMismatch, - /expected {access_token, issuer, user}, got {issuer, user}/ - ) - end - end - - context 'with a missing issuer' do - it 'raises an error' do - expect do - described_class.load(user: user_attributes, access_token: token_attributes) - end - .to raise_error( - Identity::SchemaMismatch, - /expected {access_token, issuer, user}, got {access_token, user}/ - ) - end - end - - context 'with an incorrect issuer' do - it 'raises an error' do - expect do - described_class.load( - user: user_attributes, - access_token: token_attributes, - issuer: 'nope' - ) - end - .to raise_error( - Identity::IssuerMismatch, - /expected #{Identity.config.issuer.inspect}, got "nope"/ - ) - end - end - end - - # ------------------------------------------------------------------------------------------------ - - describe '.load_fresh' do - let(:user_attributes) do - { - 'id' => '123', - 'roles' => %w[admin], - 'email' => 'hello@example.org', - 'name' => 'John Doe' - } - end - - let(:token_attributes) do - { - 'token' => '__access_token__', - 'refresh_token' => '__refresh_token__', - 'created_at' => Time.now.to_i, - 'expires_at' => expires_at.to_i - } - end - - let(:result) do - described_class.load_fresh( - user: user_attributes, - access_token: token_attributes, - issuer: Identity.config.issuer - ) - end - - let(:session) do - result.first - end - - context 'when the access token is valid' do - let(:expires_at) { Time.now + 3600 } - - it 'returns a session as the first value' do - expect(session).to be_a(described_class) - end - - it 'returns that the session was not refreshed' do - expect(result.last).to be(false) - end - - it 'sets the access token' do - expect(session.access_token.token).to eq('__access_token__') - end - - it 'sets the user' do - expect(session.user.email).to eq('hello@example.org') - end - end - - context 'when the access token is expired' do - let(:expires_at) { Time.now - 1 } - - before do - conn = HTTPClientHelpers.fake_client do |stub| - stub.post('oauth/token') do |_env| - [ - 200, - { 'Content-Type': 'application/json; charset=utf-8' }, - { - 'access_token' => '__refreshed_access_token__', - 'token_type' => 'Bearer', - 'expires_in' => 7200, - 'refresh_token' => '__refreshed_refresh_token__', - 'scope' => 'openid profile email scenarios:read', - 'created_at' => Time.now.to_i, - 'id_token' => '' - }.to_json - ] - end - - stub.get('oauth/userinfo') do |_env| - [ - 200, - { 'Content-Type': 'application/json; charset=utf-8' }, - { - 'sub' => user_attributes['id'], - 'email' => 'new@example.org', - 'roles' => user_attributes['roles'], - 'name' => user_attributes['name'] - }.to_json - ] - end - end - - allow(Identity).to receive(:http_client).and_return(conn) - end - - it 'returns a new session as the first value' do - expect(session).to be_a(described_class) - end - - it 'returns that the session was refreshed' do - expect(result.last).to be(true) - end - - it 'refreshes the access token' do - expect(session.access_token.token).to eq('__refreshed_access_token__') - end - - it 'refreshes the user data' do - expect(session.user.email).to eq('new@example.org') - end - end - end - - # ------------------------------------------------------------------------------------------------ - - describe '#dump' do - let(:user) do - Identity::User.new( - id: '123', - email: 'hello@example.org', - roles: %w[user], - name: 'John Doe' - ) - end - - let(:access_token) do - instance_double(Identity::AccessToken, dump: { a: 1 }) - end - - let(:session) do - described_class.new(user: user, access_token: access_token) - end - - it 'dumps the access token' do - expect(session.dump[:access_token]).to eq(a: 1) - end - - it 'dumps the user' do - expect(session.dump[:user]).to eq(user.dump) - end - - it 'dumps the issuer' do - expect(session.dump[:issuer]).to eq(Identity.config.issuer) - end - end -end diff --git a/spec/identity/token_decoder_spec.rb b/spec/identity/token_decoder_spec.rb new file mode 100644 index 0000000..e4ac540 --- /dev/null +++ b/spec/identity/token_decoder_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +RSpec.describe Identity::TokenDecoder do + let(:key) { OpenSSL::PKey::RSA.new(2048) } + let(:jwk_set) { { 'keys' => [JWT::JWK.new(key.public_key, 'test_key').export] } } + + let(:claims) do + { + iss: Identity.config.issuer, + aud: Identity.config.client_uri, + sub: '123', + exp: 1.hour.from_now.to_i, + scopes: %w[public] + } + end + + def sign(payload, signing_key: key, kid: 'test_key') + JWT.encode(payload, signing_key, 'RS256', kid: kid) + end + + before { allow(described_class).to receive(:jwk_set).and_return(jwk_set) } + + describe '.decode' do + it 'decodes and returns a valid token' do + decoded = described_class.decode(sign(claims)) + + expect(decoded['sub']).to eq('123') + expect(decoded['aud']).to eq(Identity.config.client_uri) + end + + it 'strips the etm_ prefix' do + expect { described_class.decode("etm_#{sign(claims)}") }.not_to raise_error + end + + it 'strips the etm_beta_ prefix' do + expect { described_class.decode("etm_beta_#{sign(claims)}") }.not_to raise_error + end + + it 'rejects a wrong issuer' do + expect { described_class.decode(sign(claims.merge(iss: 'https://evil'))) } + .to raise_error(Identity::TokenDecoder::DecodeError) + end + + it 'rejects a wrong audience' do + expect { described_class.decode(sign(claims.merge(aud: 'other_client'))) } + .to raise_error(Identity::TokenDecoder::DecodeError) + end + + it 'accepts an array audience that includes the client' do + aud = ['https://other.example', Identity.config.client_uri] + expect { described_class.decode(sign(claims.merge(aud: aud))) }.not_to raise_error + end + + it 'rejects an array audience that excludes the client' do + expect { described_class.decode(sign(claims.merge(aud: %w[https://a https://b]))) } + .to raise_error(Identity::TokenDecoder::DecodeError) + end + + it 'rejects a missing subject' do + expect { described_class.decode(sign(claims.merge(sub: nil))) } + .to raise_error(Identity::TokenDecoder::DecodeError) + end + + it 'rejects an expired token' do + expect { described_class.decode(sign(claims.merge(exp: 1.hour.ago.to_i))) } + .to raise_error(Identity::TokenDecoder::DecodeError) + end + + it 'rejects a token signed by an unknown key' do + expect { described_class.decode(sign(claims, signing_key: OpenSSL::PKey::RSA.new(2048))) } + .to raise_error(Identity::TokenDecoder::DecodeError) + end + end + + describe '.decode key rotation' do + it 'busts the cache and retries when the kid is not found' do + stale = { 'keys' => [JWT::JWK.new(OpenSSL::PKey::RSA.new(2048).public_key, 'old').export] } + + allow(described_class).to receive(:jwk_set).and_return(stale, jwk_set) + + # First lookup misses (stale set); the jwt gem retries once with invalidate: true. + expect { described_class.decode(sign(claims)) }.not_to raise_error + expect(described_class).to have_received(:jwk_set).with(invalidate: true) + end + end +end diff --git a/spec/identity/user_spec.rb b/spec/identity/user_spec.rb index be91a21..eefede5 100644 --- a/spec/identity/user_spec.rb +++ b/spec/identity/user_spec.rb @@ -1,14 +1,13 @@ # frozen_string_literal: true RSpec.describe Identity::User do - describe '.load' do - context 'with valid data' do + describe '.from_jwt_claims' do + context 'with a valid claims hash' do let(:user) do - described_class.load( - id: '123', - roles: %w[admin], - email: 'hello@example.org', - name: 'John Doe' + described_class.from_jwt_claims( + 'sub' => '123', + 'roles' => %w[admin], + 'user' => { 'email' => 'hello@example.org', 'name' => 'John Doe' } ) end @@ -29,114 +28,38 @@ end end - context 'when the e-mail is not a string' do - it 'raises an error' do - expect { described_class.load(id: '123', roles: %w[admin], email: 123, name: 'John Doe') } - .to raise_error(Identity::Error, /123 violates constraints/) + context 'with no top-level roles claim, but an admin flag' do + let(:user) do + described_class.from_jwt_claims('sub' => '123', 'user' => { 'admin' => true }) end - end - context 'when the e-mail is nil' do - it 'sets no e-mail address' do - user = described_class.load(id: '123', roles: %w[admin], email: nil, name: 'John Doe') - expect(user.email).to be_nil + it 'derives the admin role' do + expect(user.roles).to eq(Set.new(%w[admin])) end end - context 'when the ID is nil' do - it 'raises an error' do - expect { described_class.load(id: nil, roles: %w[admin], email: '', name: '') } - .to raise_error(Identity::Error, /nil violates constraints/) + context 'with no top-level roles claim and no admin flag' do + let(:user) do + described_class.from_jwt_claims('sub' => '123', 'user' => {}) end - end - context 'when the ID is an integer' do - it 'sets the ID as a string' do - user = described_class.load(id: 123, roles: %w[admin], email: '', name: '') - expect(user.id).to eq('123') + it 'has no roles' do + expect(user.roles).to be_empty end end - context 'when a required key is not provided' do + context 'when the ID is nil' do it 'raises an error' do - expect { described_class.load(roles: %w[admin], email: 'hello@example.org', name: 'John') } - .to raise_error(Identity::Error, /Schema version mismatch/) - end - end - - context 'with an extra value' do - it 'raises a SchemaError' do - expect do - described_class.load( - id: '123', - roles: %w[admin], - email: 'hello@example.org', - name: 'John Doe', - extra: 'value' - ) - end.to raise_error(Identity::Error, /Schema version mismatch/) - end - end - - context 'with a missing value' do - it 'raises a SchemaError' do - expect do - described_class.load( - id: '123', - roles: %w[admin], - email: 'hello@example.org' - ) - end.to raise_error(Identity::Error, /Schema version mismatch/) + expect { described_class.from_jwt_claims('sub' => nil, 'user' => {}) } + .to raise_error(Identity::Error, /nil violates constraints/) end end - end - describe '.from_omniauth_hash' do - context 'with a valid hash' do - let(:user) do - described_class.from_omniauth_hash( - 'sub' => '123', - 'email' => 'hello@example.org', - 'name' => 'John Doe', - 'roles' => %w[admin] - ) - end - - it 'sets the ID' do + context 'when the ID is an integer' do + it 'sets the ID as a string' do + user = described_class.from_jwt_claims('sub' => 123, 'user' => {}) expect(user.id).to eq('123') end - - it 'sets the e-mail' do - expect(user.email).to eq('hello@example.org') - end - - it 'sets the name' do - expect(user.name).to eq('John Doe') - end - - it 'sets the roles' do - expect(user.roles).to eq(Set.new(%w[admin])) - end - end - end - - describe '#dump' do - let(:user) do - described_class.new( - id: '123', - email: 'hello@example.org', - name: 'John Doe', - roles: %w[user admin] - ) - end - - it 'includes all the values' do - expect(user.dump).to eq( - id: '123', - email: 'hello@example.org', - name: 'John Doe', - roles: %w[user admin] - ) end end diff --git a/spec/requests/identity/auth_spec.rb b/spec/requests/identity/auth_spec.rb index 806af66..5158df3 100644 --- a/spec/requests/identity/auth_spec.rb +++ b/spec/requests/identity/auth_spec.rb @@ -3,6 +3,42 @@ RSpec.describe 'Auth', type: :request do after { Identity.reset_config } + describe 'GET /auth/failure' do + context 'with a real OAuth error' do + it 'renders the failure page' do + get '/auth/failure', params: { error: 'server_error' } + + expect(response).to have_http_status(:ok) + expect(response.body).not_to be_empty + end + end + end + + describe 'GET /auth/identity (sign-in page)' do + context 'when not signed in' do + it 'renders the page carrying the silent recovery probe' do + get '/auth/identity' + + expect(response).to have_http_status(:ok) + expect(response.body).to include('/session/refresh') + end + end + + context 'when already signed in (session was silently recovered)' do + before do + Identity.config.client_id = 'abc123' + mock_omniauth_user_sign_in + get '/auth/identity/callback' + end + + it 'forwards the visitor on instead of showing the sign-in button' do + get '/auth/identity' + + expect(response).to redirect_to('/') + end + end + end + describe 'POST /auth/logout' do context 'when not signed in' do it 'redirects to the root page' do @@ -27,14 +63,15 @@ expect(response.location).to start_with("#{Identity.config.issuer}/identity/sign_out") end - it 'includes the access token in the redirect query string' do + it 'builds an RP-initiated logout URL with the client id and no access token' do post '/auth/sign_out' uri = URI.parse(response.location) query = Rack::Utils.parse_nested_query(CGI.unescape(uri.query)) expect(query).to eq( - 'access_token' => OmniAuth.config.mock_auth[:identity]['credentials']['token'] + 'client_id' => Identity.config.client_id, + 'post_logout_redirect_uri' => "#{Identity.config.client_uri}/" ) end end