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('.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 %> +