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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
config/*.local.*


# Ignore all logfiles and tempfiles.
/log/*
Expand Down
65 changes: 12 additions & 53 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,74 +1,33 @@
# syntax = docker/dockerfile:1

# Base Stage: Ruby and dependencies
# Development image: single stage, all gem groups (no BUNDLE_WITHOUT), source mounted,
# assets compiled on the fly. Used by this repo's docker-compose.yml and by ETLauncher
# via `build.context: ../my-etm`. The deploy build lives in Dockerfile.production.
ARG RUBY_VERSION=4.0.2-slim
FROM ruby:${RUBY_VERSION} AS base
FROM ruby:${RUBY_VERSION}

LABEL maintainer="info@energytransitionmodel.com"

# Set working directory
WORKDIR /app

# Install required base packages
RUN apt-get update -yqq && \
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
default-mysql-client \
git \
gnupg \
libjemalloc2 \
libvips \
libyaml-dev \
nodejs \
git \
gnupg && \
rm -rf /var/lib/apt/lists /tmp/* /var/tmp/*

# Set environment variables for production
ENV RAILS_ENV=production \
BUNDLE_DEPLOYMENT=1 \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development:test" \
SECRET_KEY_BASE_DUMMY=1

# Throw-away Build Stage: Gems and assets
FROM base AS build

# Install additional build tools for native extensions
RUN apt-get update -yqq && \
apt-get install --no-install-recommends -y \
default-libmysqlclient-dev \
git \
pkg-config && \
rm -rf /var/lib/apt/lists /tmp/* /var/tmp/*
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Copy Gemfile and install dependencies
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle "${BUNDLE_PATH}/ruby/*/cache" "${BUNDLE_PATH}/ruby/*/bundler/gems/*/.git"
RUN bundle install
RUN bundle exec rails tailwindcss:build

# Copy application code
COPY . .

# Precompile Rails assets
RUN bundle exec rails assets:precompile && \
bundle exec bootsnap precompile app/ lib/

# Final Stage: Minimal runtime image
FROM base

# Copy built gems and application from the build stage
COPY --from=build ${BUNDLE_PATH} ${BUNDLE_PATH}
COPY --from=build /app /app

# Create a non-root user for runtime
RUN groupadd --system rails && \
useradd --system --gid rails --create-home rails && \
chown -R rails:rails /app
USER rails

# Expose Rails server port
EXPOSE 3000

# Entrypoint to prepare the database
ENTRYPOINT ["./bin/docker-entrypoint"]

# Default command to start Rails server
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0", "-p", "3000"]
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,37 @@ After setting up MyETM, configure it to communicate with ETEngine and ETModel:
4. Copy the generated configuration into `config/settings.local.yml` for both ETEngine and ETModel.
Copy it into `.env.local` for Collections.

### Cross-app single sign-on (SSO) in development

Once signed in to one app, the other apps recognise the session and sign you in **silently** (no
second login). This is driven by a shared JWT session cookie, `etm_session`, that MyETM mints and
every other app reads as the browser session. Browsers only share a cookie across apps that sit
under a **common parent domain**, so the apps must run on sibling subdomains of one parent. MyETM
scopes the cookie to the parent given by `SSO_COOKIE_DOMAIN` (default `.energytransitionmodel.com`,
i.e. production).

Plain `localhost:3000`, `localhost:3001`, … are all the **same host** on different ports (cookies
ignore the port), so there is no shared parent and silent SSO cannot work between them. You have two
options:

- **ETLauncher (recommended):** it already runs every app on a `*.local.energytransitionmodel.com`
subdomain and sets `SSO_COOKIE_DOMAIN=.local.energytransitionmodel.com`, so SSO works out of the box.
- **Running the apps separately:** give them a shared parent domain via `/etc/hosts`. For example,
add:

```text
127.0.0.1 myetm.etm.test etengine.etm.test etmodel.etm.test collections.etm.test
```

Then:
1. Start MyETM with `SSO_COOKIE_DOMAIN=etm.test` (e.g. `SSO_COOKIE_DOMAIN=etm.test bin/dev -p 3002`).
2. Open each app at its `*.etm.test` host (e.g. `http://myetm.etm.test:3002`), not `localhost`.
3. In each client app's `config/settings.local.yml` (Collections: `.env.local`), set `client_uri`
and the MyETM `issuer` to the matching `*.etm.test` URLs so the OAuth `iss`/`aud` line up.

> Silent SSO is a convenience only. If you skip this setup, every app still works — you simply log
> in once per app. **Single logout** and the short access-token TTL behave correctly regardless.

---

## Database Setup and Importing Scenarios (Admin Only)
Expand Down
31 changes: 25 additions & 6 deletions app/controllers/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,38 @@ class BaseController < ActionController::API
def current_user
return @current_user if defined?(@current_user)

if doorkeeper_token
@current_user = User.find(doorkeeper_token.resource_owner_id)
end
@current_user =
if doorkeeper_token
User.find(doorkeeper_token.resource_owner_id)
elsif session_token_claims
User.find_by(id: session_token_claims["sub"])
end
end

def current_ability
@current_ability ||= begin
@current_ability ||=
if current_user
TokenAbility.new(doorkeeper_token, current_user)
TokenAbility.new(doorkeeper_token || session_token_claims, current_user)
else
GuestAbility.new
end
end
end

# Claims of a self-issued identity JWT (the shared session cookie) presented as a bearer token
# but not stored as a Doorkeeper token. Verified locally against MyETM's signing key, so the
# cookie authenticates here exactly as it does at ETEngine. nil for Doorkeeper tokens (PATs,
# OAuth) and unauthenticated requests. TokenAbility reads scopes straight from this claims hash.
def session_token_claims
return @session_token_claims if defined?(@session_token_claims)

bearer = request.authorization.to_s[/\ABearer (.+)\z/, 1]
@session_token_claims = bearer && MyEtm::Auth.verify_jwt(bearer)
end

# The granted scopes, from a stored Doorkeeper token or, for the shared session cookie, the
# verified JWT claims. Used when forwarding the user's scopes to downstream engine calls.
def current_scopes
doorkeeper_token&.scopes || Array(session_token_claims&.dig("scopes"))
end

# Send a 404 response with an optional JSON body.
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/saved_scenario_users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def engine_client
MyEtm::Auth.engine_client(
current_user,
@saved_scenario.version,
scopes: doorkeeper_token ? doorkeeper_token.scopes : []
scopes: current_scopes
)
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/saved_scenarios_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def engine_client
MyEtm::Auth.engine_client(
current_user,
active_version,
scopes: doorkeeper_token ? doorkeeper_token.scopes : []
scopes: current_scopes
)
end

Expand Down
37 changes: 37 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
class ApplicationController < ActionController::Base
include JwtSessionCookies

helper :all

# Only allow modern browsers supporting webp images, web push, badges, import maps,
# CSS nesting, and CSS :has.
allow_browser versions: :modern
prepend_before_action :recover_jwt_session
before_action :set_locale
before_action :configure_sentry
before_action :store_user_location!, if: :storable_location?
Expand Down Expand Up @@ -43,13 +46,47 @@ def active_version_tag
session[:active_version_tag] || Version.default.tag
end

def current_user
return @current_user if defined?(@current_user)

@current_user = session_claims && User.find_by(id: session_claims["sub"])
end

# How many items are in the trash?
def trash_item_count
current_user.saved_scenarios.discarded.count + current_user.collections.discarded.count
end

private

def session_claims
return @session_claims if defined?(@session_claims)

token = cookies[JwtSessionCookies::SESSION_COOKIE]
@session_claims = token.present? ? MyEtm::Auth.verify_jwt(token) : nil
end

# Slides the shared session server-side when the access JWT has expired but the 24h refresh cookie
# is still valid. Without this, a request arriving after the 10-minute access token lapsed is
# treated as logged out even though the user could be silently re-authenticated
def recover_jwt_session
return if session_claims
return if cookies[JwtSessionCookies::REFRESH_COOKIE].blank?

if renew_jwt_session
reset_session_identity
else
clear_jwt_session_cookies
end
end

# renew_jwt_session writes a new etm_session cookie during this request; clear the memoized claims
# and user so current_user re-reads the fresh cookie from the jar.
def reset_session_identity
remove_instance_variable(:@session_claims) if defined?(@session_claims)
remove_instance_variable(:@current_user) if defined?(@current_user)
end

def require_user
return if current_user

Expand Down
22 changes: 22 additions & 0 deletions app/controllers/browser_sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

# Refreshes the shared JWT browser session. Apps call this (a credentialed cross-subdomain fetch)
# shortly before the access JWT expires, or after a 401, to slide the session without re-login.
class BrowserSessionsController < ApplicationController
# The refresh cookie is the credential and is SameSite=Lax (so a cross-site POST can't carry it);
# the request comes from sibling subdomains via fetch, which won't send a Rails CSRF token.
skip_before_action :verify_authenticity_token, only: :refresh

# #refresh renews the session itself; the inherited recovery filter would otherwise rotate the
# refresh token a first, redundant time before the action rotates it again.
skip_before_action :recover_jwt_session, only: :refresh

def refresh
if renew_jwt_session
head :no_content
else
clear_jwt_session_cookies
head :unauthorized
end
end
end
94 changes: 94 additions & 0 deletions app/controllers/concerns/jwt_session_cookies.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true

# Establishes and clears the shared domain-level JWT browser session.
#
# Three cookies make up the session (all set by MyETM, the only app that holds the signing key and
# can write the parent domain):
# - etm_session : short-lived identity JWT, read by every subdomain app as the browser session.
# - etm_refresh : opaque refresh token, host-only to MyETM so the long-lived secret never
# reaches other subdomains. Backed by one of the user's Doorkeeper access
# tokens, so RevokeUserSessions revokes it on logout (single logout).
# - etm_session_exp : the access JWT's expiry (no PII, not HttpOnly) so client JS can refresh
# shortly before expiry without reading the HttpOnly session cookie.
module JwtSessionCookies
extend ActiveSupport::Concern

SESSION_COOKIE = "etm_session"
REFRESH_COOKIE = "etm_refresh"
SESSION_EXP_COOKIE = "etm_session_exp"

ACCESS_TTL = 10.minutes
REFRESH_TTL = 24.hours
SESSION_SCOPES = "openid profile email roles scenarios:read scenarios:write scenarios:delete"

private

# Mints a fresh Doorkeeper access token (app-less: it belongs to the user directly, not an
# OAuthApplication) plus a Doorkeeper-generated refresh token, then writes the cookies. The token's
# own `.token` value IS the access JWT — Doorkeeper::JWT (configured in doorkeeper_jwt.rb) is the
# only JWT minter system-wide, so there's no second, separately-signed JWT to keep in sync with it.
def start_jwt_session(user)
access = user.access_tokens.create!(
expires_in: ACCESS_TTL, scopes: SESSION_SCOPES, use_refresh_token: true
)
write_session_cookies(access)
end

# Refreshes the session from the refresh cookie via Doorkeeper's own refresh grant (the same
# atomic validate/mint-new logic behind grant_type=refresh_token at the token endpoint). `nil`
# credentials is correct here: the anchor token has no `application`, so Doorkeeper's client checks
# are skipped (see RefreshTokenRequest#validate_*).
#
# Returns false if the token is missing, revoked, or has been idle past the sliding window — i.e.
# the caller should clear the cookies and return 401.
def renew_jwt_session
old = Doorkeeper::AccessToken.by_refresh_token(cookies[REFRESH_COOKIE])
return false unless old && refresh_token_live?(old)

response = Doorkeeper::OAuth::RefreshTokenRequest.new(Doorkeeper.config, old, nil, {}).authorize
return false unless response.is_a?(Doorkeeper::OAuth::TokenResponse)

old.revoke unless old.revoked?
write_session_cookies(response.token)
true
rescue Doorkeeper::Errors::InvalidGrantReuse
# The refresh token was revoked concurrently
false
end

def write_session_cookies(access)
# Max-Age matches each cookie's lifetime so an expired session cookie is dropped rather than
# lingering: the access cookie dies with its JWT, the refresh cookie spans the sliding window.
cookies[SESSION_COOKIE] =
parent_cookie_options.merge(value: access.token, httponly: true, expires: ACCESS_TTL)
cookies[REFRESH_COOKIE] =
refresh_cookie_options.merge(value: access.refresh_token, httponly: true, expires: REFRESH_TTL)
cookies[SESSION_EXP_COOKIE] =
parent_cookie_options.merge(value: ACCESS_TTL.from_now.to_i.to_s, expires: ACCESS_TTL)
end

def clear_jwt_session_cookies
cookies.delete(SESSION_COOKIE, **parent_cookie_options.slice(:domain))
cookies.delete(SESSION_EXP_COOKIE, **parent_cookie_options.slice(:domain))
cookies.delete(REFRESH_COOKIE)
end

def refresh_token_live?(token)
token.created_at + REFRESH_TTL > Time.current
end

# Parent-domain cookie options, so every sibling subdomain reads it. Secure only under TLS so the
# HTTP dev stack works.
def parent_cookie_options
options = { secure: request.ssl?, same_site: :lax }
domain = Settings.auth.sso_cookie_domain.to_s.delete_prefix(".")
options[:domain] = ".#{domain}" if domain.present? && request.host.end_with?(domain)
options
end

# Refresh cookie is host-only to MyETM (no Domain), keeping the long-lived secret off every other
# subdomain. A credentialed cross-subdomain fetch to MyETM's refresh endpoint still sends it.
def refresh_cookie_options
{ secure: request.ssl?, same_site: :lax }
end
end
2 changes: 1 addition & 1 deletion app/controllers/identity/identity_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module IdentityController

included do
# layout 'identity'
before_action :authenticate_user!
before_action :require_user
before_action :set_back_url
end

Expand Down
Loading
Loading