Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion app/models/course/assessment/answer/programming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
57 changes: 38 additions & 19 deletions app/models/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
18 changes: 10 additions & 8 deletions app/models/user/email.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions app/services/authentication/jwt_verification_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]) || {}
Expand All @@ -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
Expand All @@ -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
})
Expand Down
19 changes: 15 additions & 4 deletions app/services/authentication/keycloak_verification_service.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
16 changes: 10 additions & 6 deletions app/services/codaveri_async_api_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
},
Expand All @@ -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'
},
Expand All @@ -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
Expand Down
15 changes: 10 additions & 5 deletions app/services/ssid_async_api_service.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Comment thread
adi-herwana-nus marked this conversation as resolved.
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const InstancesTable = (props: InstanceTableProps): JSX.Element => {
<InstanceField
field="host"
for={instance}
link={`//${instance.host}/admin/instances`}
link={`${instance.redirectUri}/admin/instances`}
/>
),
},
Expand Down
2 changes: 2 additions & 0 deletions client/app/types/system/instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface InstanceBasicListData {
id: number;
name: string;
host: string;
redirectUri: string;
}

export interface InstanceListData extends InstanceBasicListData {
Expand All @@ -26,6 +27,7 @@ export interface InstanceBasicMiniEntity {
id: number;
name: string;
host: string;
redirectUri: string;
}

export interface InstanceMiniEntity extends InstanceBasicMiniEntity {
Expand Down
Loading