Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5db78ac
Serve Code Editor pages from editor-api on the editor host
zetter-rpf Sep 30, 2026
e8dd580
Stop CORP header depending on how origins are read from ENV
zetter-rpf Sep 30, 2026
0327b47
Render the Code Editor home page on the server
zetter-rpf Sep 30, 2026
de5bc02
Show the global navigation on Code Editor pages
zetter-rpf Sep 30, 2026
f88c2fd
Give the Code Editor home page its secondary navigation
zetter-rpf Sep 30, 2026
d61ad7c
Add the footer to Code Editor pages
zetter-rpf Sep 30, 2026
37ed492
Keep en-US pages working once UploadJob has loaded
zetter-rpf Sep 30, 2026
1f02da6
Document the Code Editor migration in the engine
zetter-rpf Sep 30, 2026
db5b241
Point agent instructions at the Code Editor migration plan
zetter-rpf Sep 30, 2026
a3f78f8
Log in to the Code Editor with its own Hydra client
zetter-rpf Sep 30, 2026
cbf0eba
Return users to the page they logged in from
zetter-rpf Sep 30, 2026
2287c6f
Give the editor web component the signed-in user's token
zetter-rpf Sep 30, 2026
f905240
Keep the editor signed in without reloading the page
zetter-rpf Sep 30, 2026
dbafd9f
Record how Code Editor auth was built in the migration plan
zetter-rpf Sep 30, 2026
6cd002e
Stop the editor pages loading a JavaScript module that is absent
zetter-rpf Sep 30, 2026
db69ae4
Serve the Code Editor project page from editor-api
zetter-rpf Sep 30, 2026
9375227
Let people list, create, rename and delete their projects
zetter-rpf Sep 30, 2026
ceda7c5
Record the project pages in the migration plan
zetter-rpf Sep 30, 2026
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ HYDRA_PUBLIC_TOKEN_URL=http://host.docker.internal:9001 # Internal docker addres
HYDRA_CLIENT_ID=editor-dashboard-dev
HYDRA_CLIENT_SECRET=secret

# The hosts in EDITOR_APP_HOSTS authenticate against the editor client instead,
# which is public (no secret, PKCE) so that Profile resolves its own roles claim.
EDITOR_HYDRA_CLIENT_ID=editor-dev

IDENTITY_URL=http://host.docker.internal:3002 # Internal docker address

SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX
Expand All @@ -36,6 +40,19 @@ SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX
HOST_URL=http://localhost:3009
EDITOR_PUBLIC_URL=http://classroom.localhost:3013

# The editor-ui build that the project page loads. A URL ending in
# `latest_version` names a file holding the current release path, which is
# fetched and cached rather than used as-is.
# EDITOR_WEB_COMPONENT_URL=https://staging-editor-static.raspberrypi.org/branches/main
EDITOR_WEB_COMPONENT_URL=http://localhost:3011

# Scratch projects belong to Experience CS, so the project page hands them back.
EXPERIENCE_CS_WEB_URL=https://staging.experience-cs.org

# Hosts served by the EditorApp engine. Comma separated, literal strings or
# regexes wrapped in forward slashes (see lib/origin_parser.rb).
EDITOR_APP_HOSTS=editor.localhost

PROFILE_API_KEY=test # This has to match the value set in Profile (https://github.com/RaspberryPiFoundation/profile/blob/ca10a4f360b6fe2b04be76264e03283054126b0f/.env.example#L45).

# The application is configured to log sent emails in development. If
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ docker compose up
- Routes: `config/routes.rb`. Auth: `config/initializers/omniauth.rb`, `app/helpers/authentication_helper.rb`, `app/controllers/concerns/identifiable.rb`.
- Permissions: `app/models/ability.rb`. Domain ops: `lib/concepts/**`. Models: `app/models/**`. GraphQL: `app/graphql/**`.

## EditorApp engine (in progress)
- The Code Editor web app is being moved out of the `editor-standalone` repo into the
`EditorApp` engine at `editor_app/`, served at the hosts in `EDITOR_APP_HOSTS`.
- **Read `editor_app/PLAN.md` before working on it.** It records what is built, what is
left, the decisions already taken (and why), the cross-repo Hydra dependency, and bugs
found in the React app that must not be reintroduced.

## Security
- Never commit secrets (`.env`, `config/master.key`, API tokens, webhook secrets).
- `.env.example` contains placeholder values only.
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby file: '.tool-versions'

source 'https://rubygems.pkg.github.com/raspberrypifoundation' do
gem 'design_system_rails', '~> 0.19'
end

gem 'administrate', '~> 1.0.0'
gem 'administrate-field-active_storage'
gem 'aws-sdk-s3', require: false
Expand All @@ -13,6 +17,7 @@ gem 'bootsnap', require: false
gem 'cancancan', '~> 3.3'
gem 'countries'
gem 'csv', '~> 3.3'
gem 'editor_app', path: 'editor_app'
gem 'email_validator'
gem 'faker'
gem 'faraday'
Expand Down Expand Up @@ -50,6 +55,7 @@ gem 'ruby-vips'
gem 'rubyzip'
gem 'sentry-rails'
gem 'statesman'
gem 'view_component'

group :development, :test do
gem 'awesome_print'
Expand Down
43 changes: 43 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ GIT
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.4)

PATH
remote: editor_app
specs:
editor_app (0.1.0)
rails (>= 8.1)
view_component

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -150,6 +157,8 @@ GEM
bigdecimal
rexml
crass (1.0.7)
cssbundling-rails (1.4.3)
railties (>= 6.0.0)
csv (3.3.6)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
Expand Down Expand Up @@ -525,7 +534,17 @@ GEM
snaky_hash (2.0.7)
hashie (>= 0.1.0, < 6)
version_gem (~> 1.1, >= 1.1.14)
sprockets (4.4.1)
concurrent-ruby (~> 1.1)
logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
statesman (13.3.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
time (0.4.1)
Expand All @@ -542,6 +561,15 @@ GEM
uri (1.1.1)
useragent (0.16.11)
version_gem (1.1.14)
view_component (4.15.0)
actionview (>= 7.1.0)
activesupport (>= 7.1.0)
concurrent-ruby (~> 1)
view_component-form (0.3.1)
actionview (>= 7.2.0)
activesupport (>= 7.2.0)
view_component (>= 2.34.0, < 5.0)
zeitwerk (~> 2.5)
webdrivers (5.3.1)
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
Expand All @@ -558,6 +586,18 @@ GEM
nokogiri (~> 1.8)
zeitwerk (2.8.3)

GEM
remote: https://rubygems.pkg.github.com/raspberrypifoundation/
specs:
design_system_rails (0.19.0)
cssbundling-rails (~> 1.2)
importmap-rails
rails (>= 7.0, < 9.0)
sprockets-rails (~> 3.4)
stimulus-rails (~> 1.2)
view_component (>= 2.0, < 5.0)
view_component-form (>= 0.2.5, < 1.0)

PLATFORMS
aarch64-linux
arm64-darwin-24
Expand All @@ -579,7 +619,9 @@ DEPENDENCIES
csv (~> 3.3)
database_cleaner-active_record
debug
design_system_rails (~> 0.19)!
dotenv-rails
editor_app!
email_validator
factory_bot_rails
faker
Expand Down Expand Up @@ -634,6 +676,7 @@ DEPENDENCIES
shoulda-matchers (~> 8.0)
simplecov
statesman
view_component
webdrivers
webmock

Expand Down
27 changes: 23 additions & 4 deletions app/controllers/auth_controller.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# frozen_string_literal: true

class AuthController < ApplicationController
LOCAL_PATH = %r{\A/(?![\\/])}

def callback
Rails.logger.debug { "callback: #{omniauth_params}" }
# Prevent session fixation. If the session has been initialized before
# this, and we need to keep the data, then we should copy values over.
reset_session

self.current_user = User.from_omniauth request.env['omniauth.auth']
session[:oauth_expires_at] = request.env.dig('omniauth.auth', 'credentials', 'expires_at')

return redirect_to admin_root_path if current_user.admin?

redirect_to root_path
redirect_to post_login_path
end

def destroy
Expand All @@ -23,7 +24,7 @@ def destroy
return
end

redirect_to "#{ENV.fetch('IDENTITY_URL', nil)}/logout?returnTo=#{ENV.fetch('HOST_URL', nil)}",
redirect_to "#{ENV.fetch('IDENTITY_URL', nil)}/logout?returnTo=#{logout_return_url}",
allow_other_host: true
end

Expand All @@ -39,6 +40,24 @@ def failure

private

def post_login_path
return login_origin if login_origin
return admin_root_path if current_user.admin?

root_path
end

def login_origin
origin = request.env['omniauth.origin'].to_s
origin if origin.match?(LOCAL_PATH)
end

def logout_return_url
return request.base_url if EditorApp.serves_host?(request.host)

ENV.fetch('HOST_URL', nil)
end

def omniauth_params
request.env['omniauth.params']
end
Expand Down
59 changes: 59 additions & 0 deletions app/controllers/silent_renew_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

class SilentRenewController < ApplicationController
layout false

def start
return head :forbidden unless current_user

session[:silent_renew_state] = SecureRandom.hex(24)
session[:silent_renew_verifier] = SecureRandom.hex(64)

redirect_to authorize_url, allow_other_host: true
end

def callback
state = session.delete(:silent_renew_state)
verifier = session.delete(:silent_renew_verifier)

@renewed = params[:code].present? && matching_state?(state) && renew(params[:code], verifier)
end

private

def matching_state?(state)
state.present? && ActiveSupport::SecurityUtils.secure_compare(params[:state].to_s, state)
end

def renew(code, verifier)
token = EditorHydraClient.oauth_client.auth_code.get_token(
code, redirect_uri: silent_renew_url, code_verifier: verifier
)

self.current_user = User.from_id_token(token.params['id_token'], token.token)
session[:oauth_expires_at] = token.expires_at
true
rescue OAuth2::Error => e
Rails.logger.info { "Silent renew failed: #{e.message}" }
false
end

def authorize_url
query = {
client_id: EditorHydraClient.client_id,
redirect_uri: silent_renew_url,
response_type: 'code',
scope: EditorHydraClient::SCOPE,
state: session[:silent_renew_state],
prompt: 'none',
code_challenge: code_challenge,
code_challenge_method: 'S256'
}

"#{EditorHydraClient.authorize_url}?#{query.to_query}"
end

def code_challenge
Base64.urlsafe_encode64(Digest::SHA2.digest(session[:silent_renew_verifier]), padding: false)
end
end
9 changes: 9 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ def self.from_auth(auth)
new(args)
end

def self.from_id_token(id_token, access_token)
claims = JWT.decode(id_token, nil, false).first
args = claims.slice(*ATTRIBUTES)
args['id'] = claims['sub']
args['token'] = access_token

new(args)
end

def self.from_token(token:)
return nil if token.blank?

Expand Down
21 changes: 21 additions & 0 deletions app/views/silent_renew/callback.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Silent renew</title>
<% if @renewed %>
<%= render EditorApp::AuthTokenComponent.new(user: current_user, expires_at: session[:oauth_expires_at]) %>
<% end %>
</head>
<body>
<script>
window.parent.postMessage(
{
type: "editor-app:session-renewal",
renewed: <%= @renewed ? 'true' : 'false' %>,
expiresAt: <%= raw ERB::Util.json_escape(session[:oauth_expires_at].to_json) %>
},
window.origin
);
</script>
</body>
</html>
2 changes: 2 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class Application < Rails::Application

config.api_only = false

config.i18n.fallbacks = [:en]

config.middleware.insert_before 0, CorpMiddleware

require 'rack/content_type_default'
Expand Down
3 changes: 2 additions & 1 deletion config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@

# Enable DNS rebinding protection and other `Host` header attacks.
config.hosts = [
URI.parse(ENV.fetch('HOST_URL')).host
URI.parse(ENV.fetch('HOST_URL')).host,
*EditorApp.hosts
]

# Skip DNS rebinding protection for the default health check endpoint.
Expand Down
5 changes: 3 additions & 2 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
OmniAuth::Strategies::Rpi, ENV.fetch('HYDRA_CLIENT_ID', nil), ENV.fetch('HYDRA_CLIENT_SECRET', nil),
scope: 'openid email profile roles force-consent',
scope: EditorHydraClient::SCOPE,
callback_path: '/auth/callback',
client_options: {
site: ENV.fetch('HYDRA_PUBLIC_URL', nil),
Expand All @@ -22,7 +22,8 @@
auth_scheme: :basic_auth
},
authorize_params: {},
origin_param: 'returnTo'
origin_param: 'returnTo',
setup: ->(env) { EditorHydraClient.configure_strategy(env) }
)

OmniAuth.config.on_failure = AuthController.action(:failure)
Expand Down
12 changes: 10 additions & 2 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,17 @@

resource :github_webhooks, only: :create, defaults: { formats: :json }

root to: 'auth#index'

post '/auth/rpi', as: 'login'
get '/auth/callback', to: 'auth#callback', as: 'callback'
get '/logout', to: 'auth#destroy', as: 'logout'
get '/auth/silent_renew/start', to: 'silent_renew#start', as: 'start_silent_renew'
get '/auth/silent_renew', to: 'silent_renew#callback', as: 'silent_renew'
Comment on lines +132 to +133

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if these should be here rather than in the EditorApp


constraints(->(request) { !EditorApp.serves_host?(request.host) }) do
root to: 'auth#index'
end

constraints(->(request) { EditorApp.serves_host?(request.host) }) do
mount EditorApp::Engine => '/'
end
end
Loading
Loading