diff --git a/.gitignore b/.gitignore index 600b1d718e0..5f79c617e82 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,13 @@ # Ignore user-specific VSCode project files .vscode +# Ignore credentials files which may contain sensitive information +/config/credentials/*.crt +/config/credentials/*.key +/config/credentials/*.yml.enc +!/config/credentials/test.key +!/config/credentials/test.yml.enc + # Ignore the default SQLite database. /db/*.sqlite3 /db/*.sqlite3-journal diff --git a/README.md b/README.md index c529b7259fd..92644fa49b8 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,59 @@ Once each component has been set up and is running on their own terminals, you c email: `test@example.org` password: `Coursemology!` +### Running using HTTPS locally + +These commands should be run from the repository root directory, unless otherwise noted. + +`lvh.me` is a public domain that resolves to `127.0.0.1`. It is used instead of `localhost` because browsers enforce stricter security policies on `localhost` that can break the OAuth redirect flow over HTTPS. + +1. Generate a self-signed certificate and key for `lvh.me`: + + ```sh + openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \ + -keyout config/credentials/server.key \ + -out config/credentials/server.crt \ + -subj "/CN=lvh.me" \ + -addext "subjectAltName=DNS:lvh.me,DNS:*.lvh.me" + ``` + + Puma and the webpack dev server both use these files automatically on startup. + +2. Update the Keycloak redirect URIs to use HTTPS: + + ```sh + bundle exec rake "keycloak:push_redirect_uris[https://lvh.me:8080]" + ``` + +3. Start the app server with the public hostname: + + ```sh + RAILS_HOSTNAME=lvh.me:8080 RAILS_ENV=development bundle exec puma + ``` + +4. Start the client in HTTPS mode (from the `client/` directory): + + ```sh + yarn build:development-https + ``` + +Access the app at `https://lvh.me:8080`. Your browser will show a certificate warning for the self-signed cert — ignore it or add a security exception. + +#### Reverting to HTTP + +1. Remove the certificate files so Puma falls back to HTTP: + + ```sh + rm config/credentials/server.crt config/credentials/server.key + ``` + +2. Restore the Keycloak redirect URIs: + + ```sh + bundle exec rake "keycloak:push_redirect_uris" + ``` + +3. Restart both the app server and client using the standard commands. ## Found Boogs? diff --git a/app/models/course/assessment/answer/programming.rb b/app/models/course/assessment/answer/programming.rb index e54bbd4a914..f239bb362b6 100644 --- a/app/models/course/assessment/answer/programming.rb +++ b/app/models/course/assessment/answer/programming.rb @@ -181,7 +181,7 @@ def request_live_feedback_response(thread_id, message) def construct_live_feedback_response(status, body) @response = if status == 201 - { feedbackUrl: ENV.fetch('CODAVERI_URL'), + { feedbackUrl: CodaveriAsyncApiService.api_url, threadId: body['thread']['id'], threadStatus: body['thread']['status'], tokenId: body['token']['id'], diff --git a/app/models/instance.rb b/app/models/instance.rb index e24a1afbcf8..6a714926601 100644 --- a/app/models/instance.rb +++ b/app/models/instance.rb @@ -44,7 +44,7 @@ def find_tenant_by_host_or_default(host) end end - after_commit :push_redirect_uris_to_keycloak + after_commit :push_redirect_uris_to_keycloak, unless: -> { Rails.env.test? } validates :host, hostname: true, if: :should_validate_host? validates :name, length: { maximum: 255 }, presence: true @@ -138,31 +138,50 @@ def host super end - private + def redirect_uri + default_host = ENV.fetch('RAILS_HOSTNAME', 'localhost:8080') - def push_redirect_uris_to_keycloak - return if ENV['RAILS_ENV'] == 'test' + redirect_host = if read_attribute(:host) == '*' + default_host + else + host.gsub('coursemology.org', default_host) + end - client_id = ENV.fetch('KEYCLOAK_BE_CLIENT_ID', nil) - client_secret = ENV.fetch('KEYCLOAK_BE_CLIENT_SECRET', nil) - credentials = Keycloak::Client.get_token_by_client_credentials(client_id, client_secret) - access_token = JSON.parse(credentials)['access_token'] - service = "clients/#{ENV.fetch('KEYCLOAK_FE_CLIENT_UUID', nil)}" + protocol = if Rails.env.development? && ENV['RAILS_USE_HTTP'] + 'http' + else + 'https' + end + + "#{protocol}://#{redirect_host}" + end + + def push_redirect_uris_to_keycloak + access_token = token_from_client_credentials + frontend_client_uuid = keycloak_frontend_client_uuid(access_token) + raise "Keycloak frontend client not found for client_id: #{frontend_client_id}" if frontend_client_uuid.blank? - hosts = Instance.all.pluck(:host) - redirect_uris = hosts.map { |h| convert_host_to_redirect_uri(h) } + service = "clients/#{frontend_client_uuid}" + redirect_uris = Instance.all.map(&:redirect_uri).map { |uri| "#{uri}/*" } Keycloak::Admin.generic_put(service, nil, { redirectUris: redirect_uris }, access_token) end - def convert_host_to_redirect_uri(host) - default_host = (ENV['RAILS_ENV'] == 'development') ? 'localhost:8080' : ENV.fetch('RAILS_HOSTNAME', nil) + private + + def frontend_client_id + Rails.application.credentials.dig(:keycloak, :frontend, :client_id) + end + + def token_from_client_credentials + client_id = Rails.application.credentials.dig(:keycloak, :backend, :client_id) + client_secret = Rails.application.credentials.dig(:keycloak, :backend, :client_secret) + credentials = Keycloak::Client.get_token_by_client_credentials(client_id, client_secret) + JSON.parse(credentials)['access_token'] + end - host = if host == '*' - default_host - else - host.gsub('coursemology.org', default_host) - end - (ENV['RACK_ENV'] == 'development') ? "http://#{host}/*" : "https://#{host}/*" + def keycloak_frontend_client_uuid(access_token) + clients = Keycloak::Admin.get_clients({ clientId: frontend_client_id }, access_token) + JSON.parse(clients).dig(0, 'id') end def should_validate_host? diff --git a/app/models/user/email.rb b/app/models/user/email.rb index 855ee3062aa..bcb71d25b0a 100644 --- a/app/models/user/email.rb +++ b/app/models/user/email.rb @@ -27,15 +27,17 @@ def remove_existing_unconfirmed_secondary_email def accept_all_pending_invitations return unless confirmed? - all_unconfirmed_invitations = Course::UserInvitation.where(email: email).unconfirmed - - all_unconfirmed_invitations.each do |unconfirmed_invitation| - if enrolled_course_ids.include?(unconfirmed_invitation.course_id) - unconfirmed_invitation.confirm!(confirmer: user) - next + ActsAsTenant.without_tenant do + all_unconfirmed_invitations = Course::UserInvitation.where(email: email).unconfirmed + + all_unconfirmed_invitations.each do |unconfirmed_invitation| + if enrolled_course_ids.include?(unconfirmed_invitation.course_id) + unconfirmed_invitation.confirm!(confirmer: user) + next + end + user.build_course_user_from_invitation(unconfirmed_invitation) + unconfirmed_invitation.confirm!(confirmer: user) if user.save && user.persisted? end - user.build_course_user_from_invitation(unconfirmed_invitation) - unconfirmed_invitation.confirm!(confirmer: user) if user.save && user.persisted? end end diff --git a/app/services/authentication/jwt_verification_service.rb b/app/services/authentication/jwt_verification_service.rb index f67bc67572f..1827cc8fc43 100644 --- a/app/services/authentication/jwt_verification_service.rb +++ b/app/services/authentication/jwt_verification_service.rb @@ -2,7 +2,6 @@ class Authentication::JwtVerificationService < Authentication::VerificationService JWKS_CACHE_KEY = 'auth/jwks' - JWKS_URL = ENV['KEYCLOAK_AUTH_JWKS_URL'].freeze class << self delegate :validate_token, to: :new @@ -18,6 +17,18 @@ def validate_token(access_token) private + def jwks_url + Rails.application.credentials.dig(:keycloak, :jwks_url) + end + + def iss + Rails.application.credentials.dig(:keycloak, :iss) + end + + def aud + Rails.application.credentials.dig(:keycloak, :aud) + end + def jwk_loader lambda do |options| jwks(force: options[:invalidate]) || {} @@ -31,7 +42,7 @@ def jwks(force: false) end def fetch_jwks - jwks_uri = URI(JWKS_URL) + jwks_uri = URI(jwks_url) jwks_response = Net::HTTP.get_response(jwks_uri) JSON.parse(jwks_response.body.to_s) if jwks_response.is_a? Net::HTTPSuccess @@ -40,9 +51,9 @@ def fetch_jwks def decode_token(access_token) JWT.decode(access_token, nil, true, { algorithms: 'RS256', - iss: ENV['KEYCLOAK_ISS'], + iss: iss, verify_iss: true, - aud: ENV['KEYCLOAK_AUD'], + aud: aud, verify_aud: true, jwks: jwk_loader }) diff --git a/app/services/authentication/keycloak_verification_service.rb b/app/services/authentication/keycloak_verification_service.rb index e6ef0299010..194e06050fb 100644 --- a/app/services/authentication/keycloak_verification_service.rb +++ b/app/services/authentication/keycloak_verification_service.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class Authentication::KeycloakVerificationService < Authentication::VerificationService - KEYCLOAK_INSTROPECTION_URL = ENV['KEYCLOAK_AUTH_INSTROPECTION_URL'].freeze class << self delegate :validate_token, to: :new end @@ -21,12 +20,24 @@ def validate_token(access_token) private + def client_id + Rails.application.credentials.dig(:keycloak, :backend, :client_id) + end + + def client_secret + Rails.application.credentials.dig(:keycloak, :backend, :client_secret) + end + + def introspection_url + Rails.application.credentials.dig(:keycloak, :introspection_url) + end + def introspect_token(access_token) instropection_response = \ Keycloak::Client.get_token_introspection(access_token, - ENV['KEYCLOAK_BE_CLIENT_ID'], - ENV['KEYCLOAK_BE_CLIENT_SECRET'], - KEYCLOAK_INSTROPECTION_URL) + client_id, + client_secret, + introspection_url) JSON.parse(instropection_response.to_s) end diff --git a/app/services/codaveri_async_api_service.rb b/app/services/codaveri_async_api_service.rb index 42129a3f6ac..77a7602b455 100644 --- a/app/services/codaveri_async_api_service.rb +++ b/app/services/codaveri_async_api_service.rb @@ -3,12 +3,16 @@ class CodaveriAsyncApiService CODAVERI_API_VERSION = 2.1 - def config - ENV.fetch('CODAVERI_URL') + def self.api_url + Rails.application.credentials.dig(:codaveri, :url) + end + + def self.api_key + Rails.application.credentials.dig(:codaveri, :api_key) end def initialize(api_namespace, payload) - url = config + url = self.class.api_url @api_endpoint = "#{url}/#{api_namespace}" @payload = payload end @@ -17,7 +21,7 @@ def post connection = Excon.new(@api_endpoint) response = connection.post( headers: { - 'x-api-key' => ENV.fetch('CODAVERI_API_KEY', nil), + 'x-api-key' => self.class.api_key, 'x-api-version' => CODAVERI_API_VERSION, 'Content-Type' => 'application/json' }, @@ -30,7 +34,7 @@ def put connection = Excon.new(@api_endpoint) response = connection.put( headers: { - 'x-api-key' => ENV.fetch('CODAVERI_API_KEY', nil), + 'x-api-key' => self.class.api_key, 'x-api-version' => CODAVERI_API_VERSION, 'Content-Type' => 'application/json' }, @@ -43,7 +47,7 @@ def get connection = Excon.new(@api_endpoint) response = connection.get( headers: { - 'x-api-key' => ENV.fetch('CODAVERI_API_KEY', nil), + 'x-api-key' => self.class.api_key, 'x-api-version' => CODAVERI_API_VERSION }, query: @payload diff --git a/app/services/ssid_async_api_service.rb b/app/services/ssid_async_api_service.rb index 7c735aa44c1..1bc7998f8f7 100644 --- a/app/services/ssid_async_api_service.rb +++ b/app/services/ssid_async_api_service.rb @@ -1,13 +1,18 @@ # frozen_string_literal: true class SsidAsyncApiService - def config - ENV.fetch('SSID_URL') + def self.api_url + Rails.application.credentials.dig(:ssid, :url) end - def initialize(api_namespace, payload) + def self.api_key + Rails.application.credentials.dig(:ssid, :api_key) + end + + def initialize(api_namespace, payload, url = nil) @api_namespace = api_namespace @payload = payload + @url = url || self.class.api_url end def post @@ -42,8 +47,8 @@ def get private def connection - @connection ||= Faraday.new(url: config) do |builder| - builder.request :authorization, 'Bearer', -> { ENV.fetch('SSID_API_KEY', nil) } + @connection ||= Faraday.new(url: @url) do |builder| + builder.request :authorization, 'Bearer', -> { self.class.api_key } if @url == self.class.api_url builder.request :multipart end end diff --git a/app/views/system/admin/instances/_instance_list_data.json.jbuilder b/app/views/system/admin/instances/_instance_list_data.json.jbuilder index 7e6e4362d0f..de75e1ed181 100644 --- a/app/views/system/admin/instances/_instance_list_data.json.jbuilder +++ b/app/views/system/admin/instances/_instance_list_data.json.jbuilder @@ -2,6 +2,7 @@ json.id instance.id json.name instance.name json.host instance.host +json.redirectUri instance.redirect_uri json.activeUserCount instance.active_user_count json.userCount instance.user_count json.activeCourseCount instance.active_course_count diff --git a/client/app/bundles/system/admin/admin/components/tables/InstancesTable/index.tsx b/client/app/bundles/system/admin/admin/components/tables/InstancesTable/index.tsx index f6c8a5a03a9..8a146006ca4 100644 --- a/client/app/bundles/system/admin/admin/components/tables/InstancesTable/index.tsx +++ b/client/app/bundles/system/admin/admin/components/tables/InstancesTable/index.tsx @@ -45,7 +45,7 @@ const InstancesTable = (props: InstanceTableProps): JSX.Element => { ), }, diff --git a/client/app/types/system/instances.ts b/client/app/types/system/instances.ts index 5f8b49eb23c..cf571b6e589 100644 --- a/client/app/types/system/instances.ts +++ b/client/app/types/system/instances.ts @@ -12,6 +12,7 @@ export interface InstanceBasicListData { id: number; name: string; host: string; + redirectUri: string; } export interface InstanceListData extends InstanceBasicListData { @@ -26,6 +27,7 @@ export interface InstanceBasicMiniEntity { id: number; name: string; host: string; + redirectUri: string; } export interface InstanceMiniEntity extends InstanceBasicMiniEntity { diff --git a/client/package.json b/client/package.json index b58493b1182..69bf7608904 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "build:test": "export NODE_ENV=test && export BABEL_ENV=e2e-test && yarn run build:translations && webpack --node-env=production --config webpack.prod.js", "build:production": "export NODE_ENV=production && yarn run build:translations && webpack --node-env=production --config webpack.prod.js", "build:development": "yarn run build:translations && webpack serve --config webpack.dev.js", + "build:development-https": "yarn run build:translations && USE_DEVELOPMENT_HTTPS=1 OIDC_REDIRECT_URI=https://lvh.me:8080 webpack serve --config webpack.dev.js", "build:profile": "yarn run build:translations && webpack serve --config webpack.profile.js --progress=profile", "build:translations": "formatjs compile-folder --ast locales compiled-locales", "extract-translations": "formatjs extract \"app/**/*.{js,jsx,ts,tsx}\" --ignore='**/*.d.ts' --out-file ./locales/en.json", @@ -214,5 +215,11 @@ "devServer": { "appHost": "localhost", "serverPort": 3000 + }, + "httpsDevServer": { + "appHost": "lvh.me", + "certPath": "../config/credentials/server.crt", + "keyPath": "../config/credentials/server.key", + "serverPort": 3000 } } diff --git a/client/webpack.dev.js b/client/webpack.dev.js index 1e3d8799ddd..a7f55d67d4a 100644 --- a/client/webpack.dev.js +++ b/client/webpack.dev.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const { merge } = require('webpack-merge'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); @@ -5,8 +6,9 @@ const common = require('./webpack.common'); const packageJSON = require('./package.json'); const SERVER_PORT = packageJSON.devServer.serverPort; -const CLIENT_PORT = packageJSON.devServer.clientPort; -const APP_HOST = packageJSON.devServer.appHost; +const APP_HOST = process.env.USE_DEVELOPMENT_HTTPS + ? packageJSON.httpsDevServer.appHost + : packageJSON.devServer.appHost; const BLUE_ANSI = '\x1b[36m%s\x1b[0m'; @@ -21,6 +23,18 @@ const bypassProxyIf = [ (request) => request.url.startsWith('/oauth'), ]; +const serverConfig = process.env.USE_DEVELOPMENT_HTTPS + ? { + type: 'https', + options: { + cert: fs.readFileSync(packageJSON.httpsDevServer.certPath), + key: fs.readFileSync(packageJSON.httpsDevServer.keyPath), + }, + } + : { + type: 'http', + }; + /** * @type {import('webpack').Configuration} */ @@ -28,7 +42,7 @@ module.exports = merge(common, { mode: 'development', devtool: 'eval-cheap-module-source-map', devServer: { - port: CLIENT_PORT, + server: serverConfig, allowedHosts: [`.${APP_HOST}`], historyApiFallback: true, devMiddleware: { @@ -36,16 +50,17 @@ module.exports = merge(common, { }, proxy: [ { + secure: false, context: () => true, changeOrigin: true, onProxyReq: (proxyReq) => { proxyReq.setHeader( 'origin', - `http://${proxyReq.host}:${SERVER_PORT}`, + `${serverConfig.type}://${proxyReq.host}:${SERVER_PORT}`, ); }, router: (request) => ({ - protocol: 'http:', + protocol: `${request.protocol}:`, host: request.headers.host.split(':')[0], port: SERVER_PORT, }), diff --git a/config/credentials/README.md b/config/credentials/README.md index 900c0961c4c..f6dd29a9094 100644 --- a/config/credentials/README.md +++ b/config/credentials/README.md @@ -11,7 +11,7 @@ Each environment has two files: - `.key`, the decryption key that allows reading the `.yml.enc` file. **Keys from deployed environments (staging and production) must NEVER BE COMMITTED.** Once leaked, all secrets in the environment will be compromised. - `.yml.enc`, an encrypted YAML file containing sensitive environment data (e.g. API keys). While theoretically safe to commit because the data is unreadable without the decryption key, we currently do not commit files containing real sensitive data as an additional safety measure. -The `development` and `test` key files are checked into this repository, so local development works out of the box. The sample credentials use the same structure as staging/production but with redacted values, so external API integrations (e.g. Codaveri, AWS) will not function. +The `test` key files are checked into this repository. The sample credentials use the same structure as other environments but with redacted values, so external API integrations (e.g. Codaveri, AWS) will not function. If you are a Coursemology team member who needs working credentials, contact current staff for the appropriate credentials file. diff --git a/config/credentials/development.key b/config/credentials/development.key deleted file mode 100644 index 9a9a1030d0e..00000000000 --- a/config/credentials/development.key +++ /dev/null @@ -1 +0,0 @@ -921054b69df2e11efe48fcc33e5e6c3b \ No newline at end of file diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc deleted file mode 100644 index 41c2502d1c5..00000000000 --- a/config/credentials/development.yml.enc +++ /dev/null @@ -1 +0,0 @@ -ggx1zYTbsx1qW4tVbDVtzWhUaBWhEzCQupNuLn566FFhyKb88iHP3NVoRZ/ikLbbikowkQ74yFLrw9vRg2A3XCvEbwkU2skv86YpcYNZJAczeE2Iwc+WOGDgAtMqoYGHdmi/nXoJcTGrD3H03IObGKXU89/YZP6iARJxvofjSxieOCoD24DwlzGm3/SEROrdm7qwIhYVXYWa8jQOQc2GbI1mCQX7InRxKTjz+0YDjDb6AVwQBF1084JJuqxRlxavWrKLTl0OZPR/dHhCxS2pH8Kt27QjBcF7nYCKF+dTLsGeExF4J7DUgsi6i0ZKeB0f7XaDHTXMF46DkzHW7ggCdjDRzTOrQ1aDm1jpK2MsMk4OFnW0s7gQ4AZT+0+1b6Z6zCtWRsROlGx+EPv2b8adbI6vvHJMnMe7ycWkXlIbAcMhIZKwT1cqPvMmOls1Pw36wV0ez0CB8r0E8qLkKb4z4q7xoJLXfx2bLtBskTcDHbdPn/kUCXh7DsZF3Brvqym1+JeCpjNv1dKBOW1swwcTektvdkSusOH3EQVII3H5Qim7D/Qq15i94sPG3rkQioLl3BkOJJYnqEsd/z2W9zsH8jnY5o12jUOa+CvsJKVRA1gRWfrS7tzieYN+XkvCa1QPr5jDzC+r9e77eRH7b9FXZsQ/UhG3WchWZWPIlYCq+BK+BJFLZDxtkKUJvoIanjdBybw8xTJdPgQGbE8qb2gw9otCVN9LW/UttYlUChk=--GbjPmjAra2SfumvH--8U9MPKnGuIO5ow1+bZvChw== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc index 269d0894a34..52534e00939 100644 --- a/config/credentials/test.yml.enc +++ b/config/credentials/test.yml.enc @@ -1 +1 @@ -22UauTnIrniRSCH/x4uVoj8HBcZJHb1ET04fxlhsYVsXPZ5vXG5mr+V5D5+F5iKv4pHNpAA7Osg6b/jWiG6t5uKPvlCwRL5j4aBi03bMIc5AMBH/8s2gEfvpWSZUC0viSNT4zwfJgKdgwHs4QhBg3EfkYxLe6S3R7W/RwX/hgW8pQB5Hw3bZhpbOtDuJeP/4Of4xg51PWP2hEz4K4IF9kTMKtjiGpprIoUQ0WFhe7l8cDBWsXBZdusCH4azQHkKo0A6YtuABe+Yaq5gBb7J2GqnVss89IJRp3TILfjgpFy8CrjGx1DpiY6csHFlvqbiOxJUjk+B0oqp2qIFOrGabcNEaGCT7tvNUEJwKb9WPYLTwfNW2EW4VgXAO7hM=--bb5c6OI9HMr/h3Jd--0p6ur3vWAdKRI3SYxGArog== \ No newline at end of file +89lwO7cAd7McaavQDydLpk/3Ged/NhbvEVNj0mFPWHKytYsp5//0AJE8+17JaxTszzMw8MrBIxj5MXoLhwCYDq1CiFJTpuVeml52ee7yI7pLjy03q9L6hAUQ32cDNuF/IcPBCmq24ol0oW359Wz8UZGVQcNoEiZk2+HC3MO6y/JItAr1RSUkuZfF3BHfzvWOa2lzKuUnXUrAZZLY5waAVVrfHoC7lEO/EiBvJiK8Nb0l9DrNy+l4Pg1wj6Z/VW5FhnkOWXEzSnZoAmsbWv7lwuLdVZbGp800etBQNc6GWdo+1BU6efTEZidgefy4WOIBQLrR8LBJs+ZeiCYoU9+2htY0BXea4riZ0X3CG8KZBOpBP8HnjyloEreHnM2WEQMI0tpR5sl5WkD4hF136vOqxY+ooSs50IBzv3Cj2hZd2f9TdYuuw4H5Tb7ep7GF+r0V0It2x3Waa7R3PNfPgmY1muBj38K46bDFqbJkYL5CnAs0Qt9Xv7OC5AG9g7qHUA1/Bv2b5Z+u27GAUWEpC8MhXPqviUXvfwJRJd09vohPm64kitNZB9TWe2vz0QvlN0Z1pUzmR8oSJhbimO0aQqA42oRhTCDq3Kpxm8k2L0pN3NtYr75aGYbffxXyvGIKmu9Hu16KtW8hmtCQz9dtw6zdCfaERfWVrqHN6moByukT+9tnM0dAqJFeK0yUGj9/+XuvrUFPon0f/BrFuU7SwCpszwIE4At7svLMXDVRuZjrXKoI08xgRiGyVjLuFsGdDI1IvbNSRB/MNWhnGSf4Va4fOl8J+42McSXGgrQX6ciP5nZBCx3lfadyJmSgtUP4FFtvJTZmtVYWl4xaw2phbYYW/Lt/Nl80LSR6NWIsmmQFgl8b8zI53GFzGwO2mYSxrmmpqgn4OsM0PQBtRAf0btpO9waUONAkS8ddJWRh6RWJiHIIqtiM/Y6lM20lGsakV5E6mYsSIGERvjZD0m9/M9ctm+2SUAb7pUIkOnsadItG3VZDShSBHTSBIV88ePALCdG6Bp6pCvm5TApeXr9U7iX3Wsrf/vdwhg/68fHYBva0RNiQicBQnZ1V4S+mr143VjHwDuWezE/2A1CQcChdKtu71BbcMdMwOvcPzWkb2hLFu7dlIykSJ/50o9wZeLghY7xbol2BGu4WfIGXFZQkqESf9y2khRZB+EeKCTxgzH0Ns9pDb7Hz7X9aRkM3eSbmpi9T/C3Bj6pHF8+61IuE1vSfjVDTzOSqbFnvmSPpV8jc5DqLRTjUsBYi67IooJTU8w27P3++JehPbfnr6dE0DkCEyfHVMCmGQCQShlOXW4MOojEblzmGwPG7S5gtWHtkHaZzUpFDzrfbcnW5Nrcc5Wmrfn6aRHfClBvyULOneby3HWWdwX9WW5QTs2B7yw+Uk8DFwbk0DZxjGuAGudJiW21DJ19beRNNcySTOxnIIvlmULfK/cylYFVYL9qs2GcfcYfKZZUJFTion0dKLSJzX5ZNNz8+DMLArKFz/7X5dA52bIaLh9X5VNIQGwan7bLhqKyRyrXOGKo6G8OoY9QEzpCPQByOhh+5mziugcTUBUIeV7FqbAWe6yvw7Tnr0LGyGNeyOK4HqHmtvj586qH1rd2ZY9xxI6YjKrCvvmFFvuQICqeCjgv/BsFp4zdsp+hF1OuCifVohfTK/vwyLmC2GAQ+gZQ0IChC4c5x/HiWiKQ6yYC1pbDwg/yIUBYU+yMRsXxIekzJ2wqql54nH2ZFfRLKjlpejMv9o8F6hB2TA0dQ/QiRfvsWrUTHFRTi+Uoa64OXWJWXgvJtr//8IX7ZkffrhzjPtlnFEaHyaSwIV4fwJtTapaWXHxcq2T11lic6QyVY+2RhNHTHgKLfas/ZvvZmkHMt+ZCcmErZleLsP2hLWGOWiHBFSsdFhdB6AL3i6g==--JGy4wEF9dNfV7tqN--aB9/MyNc3MYsOt+L9RBGWQ== \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 031025244cc..21ae547ea27 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -34,6 +34,9 @@ config.cache_store = :null_store end + # Disable request forgery protection in development environment. + config.action_controller.allow_forgery_protection = false + # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local @@ -67,10 +70,10 @@ # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker - config.x.default_app_host = 'localhost' - config.x.default_host = "#{config.x.default_app_host}:3000" + config.x.default_app_host = ENV.fetch('RAILS_HOSTNAME', 'localhost:8080').gsub(/:\d+/, '') + config.x.default_host = ENV.fetch('RAILS_HOSTNAME', 'localhost:8080') - config.action_mailer.default_url_options = { host: "#{config.x.default_app_host}:3000" } + config.action_mailer.default_url_options = { host: config.x.default_app_host } # Rails 6.0.5.1 security patch # To find out more unpermitted classes and add below then uncomment diff --git a/config/initializers/keycloak.rb b/config/initializers/keycloak.rb index 649660d974b..296c8d51575 100644 --- a/config/initializers/keycloak.rb +++ b/config/initializers/keycloak.rb @@ -7,9 +7,9 @@ # controller that manage the user session Keycloak.keycloak_controller = 'session' # realm name (only if the installation file is not present) -Keycloak.realm = ENV['KEYCLOAK_REALM'] || 'coursemology' +Keycloak.realm = Rails.application.credentials.dig(:keycloak, :realm) || 'coursemology' # realm url (only if the installation file is not present) -Keycloak.auth_server_url = ENV['KEYCLOAK_AUTH_SERVER_URL'] || 'http://localhost:8443' +Keycloak.auth_server_url = Rails.application.credentials.dig(:keycloak, :auth_server_url) || 'http://localhost:8443' # The introspect of the token will be executed every time the Keycloak::Client.has_role? method is invoked, # if this setting is set to true. Keycloak.validate_token_when_call_has_role = false diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 00000000000..ba6a35105d0 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +case ENV['RAILS_ENV'] +when 'development' + CERT_PATH = File.expand_path('credentials/server.crt', __dir__) + KEY_PATH = File.expand_path('credentials/server.key', __dir__) + + if File.exist?(CERT_PATH) && File.exist?(KEY_PATH) + ssl_bind '127.0.0.1', '3000', { + cert: CERT_PATH, + key: KEY_PATH + } + else + ENV['RAILS_USE_HTTP'] = '1' + end + + environment 'development' +when 'production' + # Change to match your CPU core count + workers `cat /proc/cpuinfo | grep processor | wc -l`.to_i + + # Min and Max threads per worker + threads 1, (ENV['RAILS_MAX_THREADS'] || 5) + + app_dir = File.expand_path('..', __dir__) + tmp_dir = "#{app_dir}/tmp" + + environment 'production' + + # Set up socket location + bind 'tcp://0.0.0.0:8107' + + # Set master PID and state locations + pidfile "#{tmp_dir}/pids/puma.pid" + state_path "#{tmp_dir}/pids/puma.state" + activate_control_app + + on_worker_boot do + require 'active_record' + begin + ActiveRecord::Base.connection.disconnect! + rescue ActiveRecord::ConnectionNotEstablished => e + puts e + end + ActiveRecord::Base.establish_connection(YAML.load_file("#{app_dir}/config/database.yml")[rails_env]) + end +end diff --git a/lib/tasks/keycloak/push_redirect_uris.rake b/lib/tasks/keycloak/push_redirect_uris.rake new file mode 100644 index 00000000000..dd95a50aae7 --- /dev/null +++ b/lib/tasks/keycloak/push_redirect_uris.rake @@ -0,0 +1,11 @@ +# frozen_string_literal: true +namespace :keycloak do + task :push_redirect_uris, [:default_host_url] => :environment do |_, args| + default_host_url = args[:default_host_url] || 'http://localhost:8080' + + ENV['RAILS_HOSTNAME'] = default_host_url.gsub(/https?:\/\//, '') + ENV['RAILS_USE_HTTP'] = '1' unless default_host_url.start_with?('https://') + + Instance.new.push_redirect_uris_to_keycloak + end +end diff --git a/spec/features/system/admin/instance_management_spec.rb b/spec/features/system/admin/instance_management_spec.rb index a18d15f4bba..0a45f19042a 100644 --- a/spec/features/system/admin/instance_management_spec.rb +++ b/spec/features/system/admin/instance_management_spec.rb @@ -70,7 +70,7 @@ instances.each do |instance| expect(page).to have_selector("div.instance_name_field_#{instance.id}", exact_text: instance.name) - expect(page).to have_link(nil, href: "//#{instance.host}/admin/instances") + expect(page).to have_link(nil, href: "#{instance.redirect_uri}/admin/instances") end end diff --git a/spec/models/instance_spec.rb b/spec/models/instance_spec.rb index b038ed67882..b4700c6e564 100644 --- a/spec/models/instance_spec.rb +++ b/spec/models/instance_spec.rb @@ -117,6 +117,186 @@ end end + describe '#redirect_uri' do + around do |example| + orig_hostname = ENV.fetch('RAILS_HOSTNAME', nil) + orig_use_http = ENV.fetch('RAILS_USE_HTTP', nil) + example.run + ensure + orig_hostname.nil? ? ENV.delete('RAILS_HOSTNAME') : (ENV['RAILS_HOSTNAME'] = orig_hostname) + orig_use_http.nil? ? ENV.delete('RAILS_USE_HTTP') : (ENV['RAILS_USE_HTTP'] = orig_use_http) + end + + context 'when the instance is the default instance' do + subject(:instance) { Instance.default } + + context 'when RAILS_HOSTNAME is not set' do + before { ENV.delete('RAILS_HOSTNAME') } + + it 'falls back to https://localhost:8080' do + expect(instance.redirect_uri).to eq('https://localhost:8080') + end + end + + context 'when RAILS_HOSTNAME is "coursemology.org"' do + before { ENV['RAILS_HOSTNAME'] = 'coursemology.org' } + + it 'returns https://coursemology.org' do + expect(instance.redirect_uri).to eq('https://coursemology.org') + end + end + + context 'when RAILS_HOSTNAME is "staging.coursemology.org"' do + before { ENV['RAILS_HOSTNAME'] = 'staging.coursemology.org' } + + it 'returns https://staging.coursemology.org' do + expect(instance.redirect_uri).to eq('https://staging.coursemology.org') + end + end + + context 'when RAILS_HOSTNAME is "lvh.me:8080"' do + before { ENV['RAILS_HOSTNAME'] = 'lvh.me:8080' } + + it 'returns https://lvh.me:8080' do + expect(instance.redirect_uri).to eq('https://lvh.me:8080') + end + end + end + + context 'when the instance has host "coursemology.org"' do + subject(:instance) { build(:instance, host: 'coursemology.org') } + + context 'when RAILS_HOSTNAME is not set' do + before { ENV.delete('RAILS_HOSTNAME') } + + it 'falls back to https://localhost:8080' do + expect(instance.redirect_uri).to eq('https://localhost:8080') + end + end + + context 'when RAILS_HOSTNAME is "coursemology.org"' do + before { ENV['RAILS_HOSTNAME'] = 'coursemology.org' } + + it 'returns https://coursemology.org' do + expect(instance.redirect_uri).to eq('https://coursemology.org') + end + end + + context 'when RAILS_HOSTNAME is "staging.coursemology.org"' do + before { ENV['RAILS_HOSTNAME'] = 'staging.coursemology.org' } + + it 'returns https://staging.coursemology.org' do + expect(instance.redirect_uri).to eq('https://staging.coursemology.org') + end + end + end + + context 'when the instance has host "tenant.coursemology.org"' do + subject(:instance) { build(:instance, host: 'tenant.coursemology.org') } + + context 'when RAILS_HOSTNAME is "coursemology.org"' do + before { ENV['RAILS_HOSTNAME'] = 'coursemology.org' } + + it 'preserves the subdomain' do + expect(instance.redirect_uri).to eq('https://tenant.coursemology.org') + end + end + + context 'when RAILS_HOSTNAME is "staging.coursemology.org"' do + before { ENV['RAILS_HOSTNAME'] = 'staging.coursemology.org' } + + it 'maps the subdomain to the staging host' do + expect(instance.redirect_uri).to eq('https://tenant.staging.coursemology.org') + end + end + end + + describe 'protocol selection' do + subject(:instance) { Instance.default } + + before { ENV.delete('RAILS_HOSTNAME') } + + context 'in a non-development environment' do + it 'uses https' do + expect(instance.redirect_uri).to start_with('https://') + end + end + + context 'in development without RAILS_USE_HTTP' do + before do + allow(Rails.env).to receive(:development?).and_return(true) + ENV.delete('RAILS_USE_HTTP') + end + + it 'uses https' do + expect(instance.redirect_uri).to start_with('https://') + end + end + + context 'in development with RAILS_USE_HTTP set' do + before do + allow(Rails.env).to receive(:development?).and_return(true) + ENV['RAILS_USE_HTTP'] = '1' + end + + it 'uses http' do + expect(instance.redirect_uri).to start_with('http://') + end + end + end + end + + describe '#push_redirect_uris_to_keycloak' do + subject(:instance) { Instance.default } + + let(:access_token) { 'test-access-token' } + let(:client_uuid) { 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } + let(:frontend_client_id) { 'coursemology-frontend' } + + before do + creds = Rails.application.credentials + allow(creds).to receive(:dig).with(:keycloak, :backend, :client_id).and_return('backend-client') + allow(creds).to receive(:dig).with(:keycloak, :backend, :client_secret).and_return('backend-secret') + allow(creds).to receive(:dig).with(:keycloak, :frontend, :client_id).and_return(frontend_client_id) + allow(Keycloak::Client).to receive(:get_token_by_client_credentials). + and_return({ access_token: access_token }.to_json) + allow(Keycloak::Admin).to receive(:get_clients). + and_return([{ 'id' => client_uuid, 'clientId' => frontend_client_id }].to_json) + allow(Keycloak::Admin).to receive(:generic_put).and_return(true) + end + + describe '#keycloak_frontend_client_uuid' do + it 'queries Keycloak using the frontend client ID' do + instance.push_redirect_uris_to_keycloak + expect(Keycloak::Admin).to have_received(:get_clients).with({ clientId: frontend_client_id }, access_token) + end + + it 'extracts the UUID from the first result' do + instance.push_redirect_uris_to_keycloak + expect(Keycloak::Admin).to have_received(:generic_put). + with("clients/#{client_uuid}", anything, anything, anything) + end + + context 'when the client list response is empty' do + before do + allow(Keycloak::Admin).to receive(:get_clients).and_return([].to_json) + end + + it 'raises an error' do + expect { instance.push_redirect_uris_to_keycloak }. + to raise_error(RuntimeError, /Keycloak frontend client not found/) + end + end + end + + it 'pushes redirect URIs for all instances' do + instance.push_redirect_uris_to_keycloak + expected_uris = Instance.all.map { |i| "#{i.redirect_uri}/*" } + expect(Keycloak::Admin).to have_received(:generic_put). + with("clients/#{client_uuid}", nil, { redirectUris: expected_uris }, access_token) + end + end + let(:instance) { create(:instance) } with_tenant(:instance) do describe '.active_course_count' do diff --git a/spec/models/user/email_spec.rb b/spec/models/user/email_spec.rb index d730d711982..6ee1f9e16ba 100644 --- a/spec/models/user/email_spec.rb +++ b/spec/models/user/email_spec.rb @@ -44,5 +44,105 @@ end end end + + describe '#accept_all_pending_invitations' do + let(:email_address) { generate(:email) } + + # Confirms a new user's email on the given instance, triggering invitation resolution. + def sign_up_on(sign_up_instance) + ActsAsTenant.with_tenant(sign_up_instance) do + create(:user_email, email: email_address) + end + end + + context 'when the invitation is on the same tenant as the sign-up' do + let!(:course) { create(:course) } + let!(:pending_invitation) do + create(:course_user_invitation, course: course, email: email_address) + end + + it 'confirms the invitation' do + sign_up_on(instance) + expect(pending_invitation.reload).to be_confirmed + end + + it 'creates a CourseUser for the invited course' do + email_record = sign_up_on(instance) + expect(email_record.user.course_users.map(&:course_id)).to include(course.id) + end + end + + context 'when the invitation is on a different tenant than the sign-up' do + let(:other_instance) { create(:instance) } + let!(:course) do + ActsAsTenant.with_tenant(other_instance) { create(:course) } + end + let!(:pending_invitation) do + ActsAsTenant.with_tenant(other_instance) do + create(:course_user_invitation, course: course, email: email_address) + end + end + + it 'confirms the invitation' do + sign_up_on(instance) + expect(pending_invitation.reload).to be_confirmed + end + + it 'creates a CourseUser for the invited course' do + email_record = sign_up_on(instance) + ActsAsTenant.without_tenant do + expect(CourseUser.exists?(user: email_record.user, course: course)).to be true + end + end + end + + context 'when there are pending invitations across multiple tenants' do + let(:other_instance) { create(:instance) } + let(:third_instance) { create(:instance) } + + let!(:course_on_instance) { create(:course) } + let!(:course_on_other_instance) do + ActsAsTenant.with_tenant(other_instance) { create(:course) } + end + + let!(:invitation_on_instance) do + create(:course_user_invitation, course: course_on_instance, email: email_address) + end + let!(:invitation_on_other_instance) do + ActsAsTenant.with_tenant(other_instance) do + create(:course_user_invitation, course: course_on_other_instance, email: email_address) + end + end + + it 'confirms all invitations regardless of tenant' do + sign_up_on(third_instance) + expect(invitation_on_instance.reload).to be_confirmed + expect(invitation_on_other_instance.reload).to be_confirmed + end + + it 'creates CourseUsers for all invited courses' do + email_record = sign_up_on(third_instance) + ActsAsTenant.without_tenant do + enrolled_course_ids = email_record.user.course_users.map(&:course_id) + expect(enrolled_course_ids).to include(course_on_instance.id, course_on_other_instance.id) + end + end + end + + context 'when the user is already enrolled in the invited course' do + let(:course) { create(:course) } + let(:user) { create(:user) } + let!(:course_user) { create(:course_user, course: course, user: user) } + let!(:pending_invitation) do + create(:course_user_invitation, course: course, email: email_address) + end + + it 'confirms the invitation without creating a duplicate CourseUser' do + email_record = create(:user_email, user: user, email: email_address, primary: false) + expect(pending_invitation.reload).to be_confirmed + expect(email_record.user.course_users.where(course: course).count).to eq(1) + end + end + end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 07aabdacbdc..e7648d9c461 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,19 +1,7 @@ # frozen_string_literal: true # This file is copied to spec/ when you run 'rails generate rspec:install' -# Env variables for rspec ENV['RAILS_ENV'] ||= 'test' -ENV['KEYCLOAK_AUTH_SERVER_URL'] ||= 'http://localhost:8443/' -ENV['KEYCLOAK_AUTH_JWKS_URL'] ||= 'http://localhost:8443/realms/coursemology_test/protocol/openid-connect/certs' -ENV['KEYCLOAK_AUTH_INSTROPECTION_URL'] ||= 'http://localhost:8443/realms/coursemology_test/protocol/openid-connect/token/introspect' -ENV['KEYCLOAK_ISS'] ||= 'http://localhost:8443/realms/coursemology_test' -ENV['KEYCLOAK_AUD'] ||= 'account' -ENV['KEYCLOAK_REALM'] ||= 'coursemology_test' -# All the codaveri endpoints are mocked, so this URL should never be called -# We use a dummy value since leaving it as null raises an error, and potentially -# in the future we can use it in stub logic to differentiate from other external APIs. -ENV['CODAVERI_URL'] ||= 'http://localhost:53896' -ENV['SSID_URL'] ||= 'http://localhost:53897' require 'spec_helper' require 'rspec/rails'