Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/assets/config/identity_manifest.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
//= link_tree ../images
//= link_directory ../stylesheets/identity .css
//= link identity/session_keeper.js
51 changes: 51 additions & 0 deletions app/assets/javascripts/identity/session_keeper.js
Original file line number Diff line number Diff line change
@@ -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);
}
31 changes: 19 additions & 12 deletions app/controllers/identity/auth_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/helpers/identity/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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. <body>) 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']

Expand Down
2 changes: 1 addition & 1 deletion app/views/identity/auth/failure.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<h1><%= t('.title') %></h1>

<% if params[:message] == 'access_denied' %>
<p><%= t('.denied') %></o>
<p><%= t('.denied') %></p>
<% else %>
<p><%= t('.description') %></p>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/identity/auth/not_authorized.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
20 changes: 20 additions & 0 deletions app/views/layouts/identity/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,25 @@
</div>
</div>

<%# 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 %>

</body>
</html>
1 change: 1 addition & 0 deletions identity.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 7 additions & 15 deletions lib/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand All @@ -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'

Expand Down
107 changes: 0 additions & 107 deletions lib/identity/access_token.rb

This file was deleted.

1 change: 0 additions & 1 deletion lib/identity/config_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading