diff --git a/Installation.md b/Installation.md index 2739c08b4..17fac6fbe 100644 --- a/Installation.md +++ b/Installation.md @@ -98,6 +98,51 @@ service postgresql restart ``` after to implement changes +#### Alternative: run PostgreSQL in a container + +The committed `config/database.yml` expects a local PostgreSQL with a `tmc` superuser +reachable over the Unix socket, which is how the project and CI run — do not change those +defaults. If you only need to run the test suite and would rather not create that role or +edit `pg_hba.conf` on your machine (for example on a shared host where you are not root), +run a throwaway PostgreSQL in Docker instead and point just the test environment at it. + +`config/database.yml` ends by ERB-including `config/database.local.yml` if it exists, so +that file can override any environment. It is gitignored — keep it that way, it is a +machine-local override and must never be committed. + +Start the container (the version should match production; 14 at the time of writing): + +```bash +docker run -d --name tmc-test-pg -p 127.0.0.1:5433:5432 \ + -e POSTGRES_USER=tmc -e POSTGRES_PASSWORD=tmc -e POSTGRES_DB=tmc-test postgres:14 +``` + +Create `config/database.local.yml`: + +```yaml +test: + adapter: postgresql + username: tmc + password: tmc + database: tmc-test + host: 127.0.0.1 + port: 5433 + pool: 25 +``` + +Load the schema and run specs: + +```bash +RAILS_ENV=test bundle exec rake db:schema:load +RAILS_ENV=test bundle exec rspec spec/services +``` + +A non-default port (5433 above) keeps the container from colliding with a system +PostgreSQL on 5432. Note this covers the test environment only — the sandbox-backed +integration specs and the dev server still want the full local setup described above. +Remove the container with `docker rm -f tmc-test-pg` and delete +`config/database.local.yml` when you are done. + ### TMC-server installation #### Clone the TMC repository diff --git a/app/controllers/api/v8/apidocs_controller.rb b/app/controllers/api/v8/apidocs_controller.rb index ff3dfc918..f8b984310 100644 --- a/app/controllers/api/v8/apidocs_controller.rb +++ b/app/controllers/api/v8/apidocs_controller.rb @@ -62,9 +62,9 @@ class ApidocsController < ActionController::Base key :required, true key :type, :integer end - parameter :path_user_email do - key :name, :user_email - key :in, :path + parameter :query_user_email do + key :name, :email + key :in, :query key :description, "User's email" key :required, true key :type, :string diff --git a/app/controllers/api/v8/base_controller.rb b/app/controllers/api/v8/base_controller.rb index a83f1c1c8..c9b362808 100644 --- a/app/controllers/api/v8/base_controller.rb +++ b/app/controllers/api/v8/base_controller.rb @@ -22,6 +22,11 @@ class BaseController < ApplicationController end end + rescue_from CoursesMoocFiTokenIntrospector::Unavailable do |e| + Rails.logger.error("courses.mooc.fi token introspection unavailable: #{e.message}") + respond_with_error('courses.mooc.fi could not verify your login right now. Try again later.', 503) + end + rescue_from ActiveRecord::RecordNotFound do |e| render json: errors_json(e.message), status: :not_found end @@ -40,12 +45,19 @@ def authenticate_user! if doorkeeper_token @current_user ||= User.find_by(id: doorkeeper_token.resource_owner_id) raise 'Invalid token' unless @current_user + elsif Rails.configuration.x.accept_courses_mooc_fi_tokens + @current_user = CoursesMoocFiAuthentication.user_for(request) + @auth_source = :courses_mooc_fi_token if @current_user end @current_user ||= user_from_session || Guest.new end attr_reader :current_user + def current_ability + @current_ability ||= ::Ability.new(current_user, auth_source: @auth_source) + end + def errors_json(messages) { errors: [*messages] } end diff --git a/app/controllers/api/v8/core/exercises/details_controller.rb b/app/controllers/api/v8/core/exercises/details_controller.rb index 736642c1d..784edfa4d 100644 --- a/app/controllers/api/v8/core/exercises/details_controller.rb +++ b/app/controllers/api/v8/core/exercises/details_controller.rb @@ -16,14 +16,12 @@ class DetailsController < Api::V8::BaseController parameter do key :in, 'query' key :name, 'ids' - schema do - key :type, :array - items do - key :type, :integer - end - end - key :type, :array key :description, 'Exercise Ids' + key :type, :array + key :collectionFormat, 'csv' + items do + key :type, :integer + end end response 200 do key :description, 'Exercises in json' diff --git a/app/controllers/api/v8/exercises/users/submissions_controller.rb b/app/controllers/api/v8/exercises/users/submissions_controller.rb index f2f436ed4..391926c2a 100644 --- a/app/controllers/api/v8/exercises/users/submissions_controller.rb +++ b/app/controllers/api/v8/exercises/users/submissions_controller.rb @@ -7,7 +7,7 @@ module Users class SubmissionsController < Api::V8::BaseController include Swagger::Blocks - swagger_path 'api/v8/exercises/{exercise_id}/users/{user_id}/submissions' do + swagger_path '/api/v8/exercises/{exercise_id}/users/{user_id}/submissions' do operation :get do key :description, 'Returns the submissions visible to the user in a json format' key :operationId, 'findUsersSubmissionsForExerciseById' @@ -33,7 +33,7 @@ class SubmissionsController < Api::V8::BaseController end end - swagger_path 'api/v8/exercises/{exercise_id}/users/current/submissions' do + swagger_path '/api/v8/exercises/{exercise_id}/users/current/submissions' do operation :get do key :description, "Returns the current user's submissions for the exercise in a json format. The exercise is searched by id." key :operationId, 'findUsersOwnSubmissionsForExerciseById' diff --git a/app/controllers/api/v8/users_controller.rb b/app/controllers/api/v8/users_controller.rb index 5b0de1977..16d110f7d 100644 --- a/app/controllers/api/v8/users_controller.rb +++ b/app/controllers/api/v8/users_controller.rb @@ -62,7 +62,7 @@ class UsersController < Api::V8::BaseController key :operationId, 'setPasswordManagedByCoursesMoocFi' key :produces, ['application/json'] key :tags, ['user'] - parameter '$ref': '#/parameters/user_id' + parameter '$ref': '#/parameters/path_user_id' parameter do key :name, :courses_mooc_fi_user_id key :in, :formData @@ -84,18 +84,17 @@ class UsersController < Api::V8::BaseController end end - swagger_path '/api/v8/users/get_user_with_email?email={email}' do + swagger_path '/api/v8/users/get_user_with_email' do operation :get do key :description, "Returns the user's id as upstream_id, user's courses.mooc.fi-id as id, email, first name and last name by user email" key :operationId, 'getUserInformationByEmail' key :produces, ['application/json'] key :tags, ['user'] - parameter '$ref': '#/parameters/user_email' + parameter '$ref': '#/parameters/query_user_email' response 403, '$ref': '#/responses/error' response 404, '$ref': '#/responses/error' response 200 do key :description, "User's courses.mooc.fi-id as id, email, first name, last name and id as upstream_id as json" - key :content, 'application/json' schema do key :title, :user key :required, [:user] @@ -249,6 +248,7 @@ def set_password_managed_by_courses_mooc_fi end user = User.find_by!(id: params[:id]) + authorize! :update, user User.transaction do user.password_managed_by_courses_mooc_fi = true user.password_hash = nil diff --git a/app/models/ability.rb b/app/models/ability.rb index fc0b89d28..5342793c3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -6,7 +6,9 @@ class Ability include CanCan::Ability - def initialize(user) + # +auth_source+ is :courses_mooc_fi_token when the user authenticated with a courses.mooc.fi + # access token; account changes are then denied to everyone, administrators included. + def initialize(user, auth_source: nil) if user.administrator? can :manage, :all can :create, Course @@ -285,5 +287,11 @@ def initialize(user) can?(:teach, o) end end + + return unless auth_source == :courses_mooc_fi_token + + # The token is scoped to exercise services; it must not take over or delete the account. + cannot :update, User + cannot :destroy, User end end diff --git a/app/models/user.rb b/app/models/user.rb index 86485036e..6157bc11b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'app_secrets' + class User < ApplicationRecord include Comparable include Gravtastic @@ -46,13 +48,18 @@ class User < ApplicationRecord message: 'does not look like an email' } + COURSES_MOOC_FI_USER_ID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/ + + # Lookups by courses.mooc.fi id (token authentication) match exactly, so store the canonical form. + normalizes :courses_mooc_fi_user_id, with: ->(id) { id.strip.downcase } + # Guard the courses.mooc.fi delegation id: it must be a valid UUID and unique. A malformed id # set here would otherwise be persisted while the local password hash is nulled, locking the # user out (they could neither log in locally nor be delegated to courses.mooc.fi). validates :courses_mooc_fi_user_id, uniqueness: true, format: { - with: /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/, + with: COURSES_MOOC_FI_USER_ID_FORMAT, message: 'must be a valid UUID' }, allow_blank: true @@ -215,7 +222,7 @@ def courses_mooc_fi_authentication_status(submitted_password) response = conn.post(auth_url) do |req| req.headers['Content-Type'] = 'application/json' req.headers['Accept'] = 'application/json' - req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project + req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project req.body = { user_id: courses_mooc_fi_user_id, @@ -269,7 +276,7 @@ def update_password_via_courses_mooc_fi(old_password, new_password) response = conn.post(update_url) do |req| req.headers['Content-Type'] = 'application/json' req.headers['Accept'] = 'application/json' - req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project + req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project req.body = { user_id: self.courses_mooc_fi_user_id, @@ -324,7 +331,7 @@ def post_new_user_to_courses_mooc_fi(password) response = conn.post(create_url) do |req| req.headers['Content-Type'] = 'application/json' req.headers['Accept'] = 'application/json' - req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project + req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project req.body = { upstream_id: id, @@ -383,7 +390,7 @@ def force_migrate_to_courses_mooc_fi response = conn.get(courses_mooc_fi_url("/api/v0/tmc-server/users-by-upstream-id/#{id}")) do |req| req.headers['Accept'] = 'application/json' - req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project + req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project end data = response.body @@ -422,7 +429,7 @@ def courses_mooc_fi_migration_status response = conn.get(courses_mooc_fi_url("/api/v0/tmc-server/users-by-upstream-id/#{id}/status")) do |req| req.headers['Accept'] = 'application/json' - req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project + req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project end data = response.body diff --git a/app/services/courses_mooc_fi_authentication.rb b/app/services/courses_mooc_fi_authentication.rb new file mode 100644 index 000000000..4062ae791 --- /dev/null +++ b/app/services/courses_mooc_fi_authentication.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Resolves the local user behind a courses.mooc.fi access token sent to API v8. +module CoursesMoocFiAuthentication + # Returns the user the request's bearer token belongs to, or nil when there is no bearer token, + # courses.mooc.fi rejects it, or it belongs to no local user. Raises + # CoursesMoocFiTokenIntrospector::Unavailable when the token cannot be checked right now. + def self.user_for(request) + token = Doorkeeper::OAuth::Token.from_bearer_authorization(request) + return nil if token.blank? + + result = CoursesMoocFiTokenIntrospector.introspect(token) + return nil unless result + + User.find_by(courses_mooc_fi_user_id: result.sub) || link_by_upstream_id(result) + end + + # Binds the user with the TMC id courses.mooc.fi reports to the token's subject, unless that user + # is already bound to another subject. + def self.link_by_upstream_id(result) + return nil if result.upstream_id.blank? + + user = User.find_by(id: result.upstream_id) + return nil unless user + return user if user.courses_mooc_fi_user_id&.casecmp?(result.sub) + + if user.courses_mooc_fi_user_id.present? + Rails.logger.warn("courses.mooc.fi upstream_id #{result.upstream_id} maps to user #{user.id}, which is bound to a different courses_mooc_fi_user_id; refusing") + return nil + end + + # Skips validation; the introspector has already checked that sub is a UUID. + user.update_column(:courses_mooc_fi_user_id, result.sub) + user + rescue ActiveRecord::RecordNotUnique + # A concurrent request bound the subject first. + User.find_by(courses_mooc_fi_user_id: result.sub) + end + private_class_method :link_by_upstream_id +end diff --git a/app/services/courses_mooc_fi_token_introspector.rb b/app/services/courses_mooc_fi_token_introspector.rb new file mode 100644 index 000000000..3556135b9 --- /dev/null +++ b/app/services/courses_mooc_fi_token_introspector.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'app_secrets' +require 'digest' + +# Checks an opaque courses.mooc.fi (secret-project-331) access token via RFC 7662 introspection. +class CoursesMoocFiTokenIntrospector + # courses.mooc.fi could not answer: transport failure, non-200 (including 401 for our own client + # credentials), unreadable body or missing configuration. Says nothing about the token. + class Unavailable < StandardError; end + + # A token courses.mooc.fi vouches for. +sub+ is the user's courses.mooc.fi id, lower case; + # +upstream_id+ the user's TMC id, if courses.mooc.fi knows it. + Result = Struct.new(:sub, :upstream_id, :expires_at, keyword_init: true) + + REQUIRED_SCOPE = 'exercise-services' + MAX_CACHE_TTL = 300 # seconds + REJECTION_CACHE_TTL = 30 # seconds + CACHE_NAMESPACE = 'courses_mooc_fi_introspection' + REJECTED = :rejected + + # Returns the Result for a token courses.mooc.fi vouches for, or nil when it rejects the token. + # Raises Unavailable when it cannot answer; that is never cached. + def self.introspect(token) + new.introspect(token) + end + + def introspect(token) + return nil if token.blank? + + cache_key = "#{CACHE_NAMESPACE}:#{Digest::SHA256.hexdigest(token)}" + cached = Rails.cache.read(cache_key) + return (cached == REJECTED ? nil : cached) unless cached.nil? + + body = request_introspection(token) + reason = reject_reason(body) + if reason + Rails.logger.warn("courses.mooc.fi token rejected: #{reason}") + Rails.cache.write(cache_key, REJECTED, expires_in: REJECTION_CACHE_TTL) + return nil + end + + result = Result.new( + sub: body['sub'].downcase, + upstream_id: body['upstream_id'], + expires_at: body['exp'].is_a?(Numeric) ? Time.at(body['exp']) : nil + ) + ttl = cache_ttl(result.expires_at) + Rails.cache.write(cache_key, result, expires_in: ttl) if ttl + result + end + + private + # sp331 serves introspection at "/introspect" and stamps every active response with + # this issuer. + def issuer + @issuer ||= "#{SiteSetting.value('courses_mooc_fi_base_url').to_s.chomp('/')}/api/v0/main-frontend/oauth" + end + + def request_introspection(token) + client_id = AppSecrets.courses_mooc_fi_introspection_client_id + client_secret = AppSecrets.courses_mooc_fi_introspection_secret + if SiteSetting.value('courses_mooc_fi_base_url').blank? || client_id.blank? || client_secret.blank? + raise Unavailable, 'courses_mooc_fi_base_url, COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID or COURSES_MOOC_FI_INTROSPECTION_SECRET is not set' + end + + connection = Faraday.new(request: { open_timeout: 2, timeout: 5 }) do |f| + f.request :url_encoded + f.response :json + end + response = connection.post( + "#{issuer}/introspect", + { token: token, client_id: client_id, client_secret: client_secret }, + 'Accept' => 'application/json' + ) + + case response.status + when 200 + raise Unavailable, 'introspection response is not a JSON object' unless response.body.is_a?(Hash) + response.body + when 401 + raise Unavailable, "courses.mooc.fi rejected tmc-server's introspection client credentials; check COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID and COURSES_MOOC_FI_INTROSPECTION_SECRET" + else + raise Unavailable, "introspection answered HTTP #{response.status}" + end + rescue Faraday::Error => e + raise Unavailable, "introspection request failed: #{e.class}: #{e.message}" + end + + # `aud` is not checked: sp331 mints every access token without an audience. + def reject_reason(body) + return 'inactive' unless body['active'] == true + return "iss #{body['iss'].inspect} is not #{issuer.inspect}" unless body['iss'] == issuer + # A DPoP-bound token proves nothing when presented as a plain bearer; sp331's own API refuses it too. + return "token_type #{body['token_type'].inspect} is not Bearer" unless body['token_type'].to_s.casecmp?('bearer') + # sp331 omits the member rather than sending false when it withholds it. + return "client_bearer_allowed #{body['client_bearer_allowed'].inspect} is not true" unless body['client_bearer_allowed'] == true + return "scope #{body['scope'].inspect} lacks #{REQUIRED_SCOPE}" unless body['scope'].to_s.split.include?(REQUIRED_SCOPE) + return "sub #{body['sub'].inspect} is not a UUID" unless body['sub'].is_a?(String) && User::COURSES_MOOC_FI_USER_ID_FORMAT.match?(body['sub']) + nil + end + + def cache_ttl(expires_at) + return MAX_CACHE_TTL if expires_at.nil? + remaining = (expires_at - Time.now).floor + [remaining, MAX_CACHE_TTL].min if remaining.positive? + end +end diff --git a/config/application.rb b/config/application.rb index 6c61f3e3a..1b3f4a4e7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,8 @@ class Application < Rails::Application config.relative_url_root = SiteSetting.value('base_path') + config.x.accept_courses_mooc_fi_tokens = ENV['ACCEPT_COURSES_MOOC_FI_TOKENS'] == 'true' + config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) do allow do origins '*' diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb index fab964bd0..6e85f4d0c 100644 --- a/config/initializers/doorkeeper_openid_connect.rb +++ b/config/initializers/doorkeeper_openid_connect.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true +require 'app_secrets' + Doorkeeper::OpenidConnect.configure do issuer 'https://tmc.mooc.fi' - signing_key Rails.application.secrets.openid_connect_signing_key + signing_key AppSecrets.openid_connect_signing_key subject_types_supported [:public] @@ -41,7 +43,7 @@ # Example implementation: # resource_owner.id - user_response = RestClient.get "https://courses.mooc.fi/api/v0/tmc-server/users-by-upstream-id/#{resource_owner.id}", { Authorization: Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project } + user_response = RestClient.get "https://courses.mooc.fi/api/v0/tmc-server/users-by-upstream-id/#{resource_owner.id}", { Authorization: AppSecrets.tmc_server_secret_for_communicating_to_secret_project } user = JSON.parse(user_response) diff --git a/config/secrets.yml b/config/secrets.yml index 804d52eb3..ba8960e37 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -42,6 +42,9 @@ development: wvTexLwtU6i38xTpGMeTsQVx -----END PRIVATE KEY----- tmc_server_secret_for_communicating_to_secret_project: <%= ENV["TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT"] || "Zm9yIGxvY2FsIGRldmVsb3BtZW50IG9ubHksIGludGVudGlvbmFsbHkgcHVibGlj" %> + # The introspection client secret-project-331 seeds for development and CI. + courses_mooc_fi_introspection_client_id: <%= ENV["COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID"] || "tmc-server-introspection-dev" %> + courses_mooc_fi_introspection_secret: <%= ENV["COURSES_MOOC_FI_INTROSPECTION_SECRET"] || "for local development only, intentionally public" %> test: secret_key_base: fdc6fb714d1447ce6ab48e3f216e5419b567dcfbc9a713273f8f193c3f13807105136de778516770d451485a8fc31e8d58eae6259e94e2a4b53f547329bb1486 @@ -75,6 +78,8 @@ test: wvTexLwtU6i38xTpGMeTsQVx -----END PRIVATE KEY----- tmc_server_secret_for_communicating_to_secret_project: <%= ENV["TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT"] || "Zm9yIGxvY2FsIGRldmVsb3BtZW50IG9ubHksIGludGVudGlvbmFsbHkgcHVibGlj" %> + courses_mooc_fi_introspection_client_id: <%= ENV["COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID"] || "tmc-server-introspection-dev" %> + courses_mooc_fi_introspection_secret: <%= ENV["COURSES_MOOC_FI_INTROSPECTION_SECRET"] || "for local development only, intentionally public" %> # Do not keep production secrets in the repository, # instead read values from the environment. @@ -82,3 +87,5 @@ production: secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> openid_connect_signing_key: <%= (ENV["OPENID_CONNECT_SIGNING_KEY"] || "").dump %> tmc_server_secret_for_communicating_to_secret_project: <%= ENV["TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT"] %> + courses_mooc_fi_introspection_client_id: <%= (ENV["COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID"] || "").dump %> + courses_mooc_fi_introspection_secret: <%= (ENV["COURSES_MOOC_FI_INTROSPECTION_SECRET"] || "").dump %> diff --git a/config/site.defaults.yml b/config/site.defaults.yml index d4c737d59..333dbdadb 100644 --- a/config/site.defaults.yml +++ b/config/site.defaults.yml @@ -139,5 +139,6 @@ course_instruction_page: http://mooc.fi/courses/general/ohjelmointi/ # Teacher manual link teacher_manual_url: http://testmycode.github.io/tmc-server/usermanual/ -# Base URL for password management and admin lookups via courses.mooc.fi, e.g. https://courses.mooc.fi +# Base URL for password management, admin lookups and access-token introspection via courses.mooc.fi, +# e.g. https://courses.mooc.fi courses_mooc_fi_base_url: diff --git a/lib/app_secrets.rb b/lib/app_secrets.rb new file mode 100644 index 000000000..6aef1f3b1 --- /dev/null +++ b/lib/app_secrets.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# config/secrets.yml, read without Rails.application.secrets (deprecated in Rails 7.1, gone in 7.2). +# In lib/ because initializers need it before autoloading is available. +module AppSecrets + class << self + def config + @config ||= Rails.application.config_for(:secrets) + end + + delegate_missing_to :config + end +end diff --git a/spec/controllers/api/v8/apidocs_controller_spec.rb b/spec/controllers/api/v8/apidocs_controller_spec.rb index 31bde373b..a63aee5af 100644 --- a/spec/controllers/api/v8/apidocs_controller_spec.rb +++ b/spec/controllers/api/v8/apidocs_controller_spec.rb @@ -5,12 +5,20 @@ describe Api::V8::ApidocsController, type: :controller do it 'json provided by controller should be valid swagger' do - pending 'Is not valid swagger at the moment' get :index json = response.body schema = File.join(Rails.root, 'spec', 'resources', 'swagger-schema.json') - validation = JSON::Validator.validate(schema, json) - expect(validation).to be_truthy + errors = JSON::Validator.fully_validate(schema, json) + expect(errors).to be_empty, -> { "Generated apidocs are not valid Swagger 2.0:\n#{errors.join("\n")}" } + end + + # The Swagger 2.0 schema only requires path keys to start with a slash, so it cannot catch a + # query string in the key; use `parameters` with `in: query`. + it 'declares no path containing a query string' do + get :index + + paths = JSON.parse(response.body)['paths'].keys + expect(paths.grep(/\?/)).to be_empty end end diff --git a/spec/controllers/api/v8/base_controller_spec.rb b/spec/controllers/api/v8/base_controller_spec.rb index 500da595d..4d3404e23 100644 --- a/spec/controllers/api/v8/base_controller_spec.rb +++ b/spec/controllers/api/v8/base_controller_spec.rb @@ -19,7 +19,7 @@ def index before :each do allow(controller).to receive(:doorkeeper_token) { token } - get :index, format: text + get :index, format: :text end context 'when not logged in' do diff --git a/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb b/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb index 49f5f4241..8b150b0f5 100644 --- a/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb +++ b/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb @@ -3,33 +3,85 @@ require 'spec_helper' describe Api::V8::Core::Exercises::SubmissionsController, type: :controller do + let(:organization) { FactoryBot.create(:accepted_organization) } + let(:course) { FactoryBot.create(:course, organization: organization) } + # returnable_forced lets the exercise accept submissions without a refreshed course repository; + # #create never reads its files. + let(:exercise) { FactoryBot.create(:returnable_exercise, course: course) } + let(:user) { FactoryBot.create(:verified_user) } + + before :each do + allow(controller).to receive(:doorkeeper_token) { token } + end + + def upload(name) + Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/submission_uploads', name), 'application/octet-stream') + end + + def create_submission(file) + params = { exercise_id: exercise.id } + params[:submission] = { file: file } if file + post :create, params: params, format: :json + end + + let(:zip_file) { upload('empty.zip') } + let(:text_file) { upload('not_a_zip.txt') } + describe 'Creating a submission' do describe 'as an authenticated user' do + let(:token) { double resource_owner_id: user.id, acceptable?: true } + it 'should accept submissions when the deadline is open' do - pending('test that submission zip is accepted before deadline') - raise + exercise.deadline_spec = ['1.1.2100'].to_json + exercise.save! + + expect { create_submission(zip_file) }.to change(Submission, :count).by(1) + + expect(response).to have_http_status :ok + json = JSON.parse(response.body) + expect(json).not_to have_key('error') + + submission = Submission.last + expect(json['submission_url']).to end_with("/api/v8/core/submissions/#{submission.id}") + expect(submission.user).to eq(user) + expect(submission.exercise_name).to eq(exercise.name) + expect(submission.course).to eq(course) end it 'should decline submissions when the deadline is closed' do - pending('test that submission zip is declined after deadline') - raise + exercise.deadline_spec = ['1.1.2000'].to_json + exercise.save! + + expect { create_submission(zip_file) }.not_to change(Submission, :count) + + expect(response).to have_http_status :forbidden + expect(JSON.parse(response.body)['error']).to eq('Submissions for this exercise are no longer accepted.') end + # Refused in the body only; the status stays 200. it 'should decline submissions when the file is not ZIP' do - pending('test that submission file is declined when not zip') - raise + expect { create_submission(text_file) }.not_to change(Submission, :count) + + expect(response).to have_http_status :ok + expect(JSON.parse(response.body)['error']).to eq("The uploaded file doesn't look like a ZIP file.") end it 'should decline submissions when a file is not selected' do - pending('test that submission is declined when no file is given') - raise + expect { create_submission(nil) }.not_to change(Submission, :count) + + expect(response).to have_http_status :not_found + expect(JSON.parse(response.body)['error']).to eq('No ZIP file selected or failed to receive it') end end describe 'as an unauthenticated user' do + let(:token) { nil } + it 'should not allow sending submission' do - pending('test that submission is declined when no file is given') - raise + expect { create_submission(zip_file) }.not_to change(Submission, :count) + + expect(response).to have_http_status :unauthorized + expect(JSON.parse(response.body)['error']).to eq('Authentication required') end end end diff --git a/spec/controllers/api/v8/courses_mooc_fi_token_auth_spec.rb b/spec/controllers/api/v8/courses_mooc_fi_token_auth_spec.rb new file mode 100644 index 000000000..7b17cf17c --- /dev/null +++ b/spec/controllers/api/v8/courses_mooc_fi_token_auth_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require 'spec_helper' + +class MoocTokenUselessController < Api::V8::BaseController +end + +RSpec.describe Api::V8::BaseController, type: :controller do + controller MoocTokenUselessController do + skip_authorization_check + def index + render plain: 'Success' + end + end + + subject(:current_user) { assigns[:current_user] } + + let(:sub) { '11111111-2222-3333-4444-555555555555' } + let(:bearer) { 'sp331-access-token' } + + def result(upstream_id: nil, subject_id: sub) + CoursesMoocFiTokenIntrospector::Result.new(sub: subject_id, upstream_id: upstream_id, expires_at: Time.now + 3600) + end + + def introspection_returns(value) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).with(bearer).and_return(value) + end + + around do |example| + original = Rails.configuration.x.accept_courses_mooc_fi_tokens + example.run + Rails.configuration.x.accept_courses_mooc_fi_tokens = original + end + + before do + allow(controller).to receive(:doorkeeper_token).and_return(nil) + request.headers['Authorization'] = "Bearer #{bearer}" + end + + context 'when the flag is off' do + before { Rails.configuration.x.accept_courses_mooc_fi_tokens = false } + + it 'never introspects a bearer token' do + expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) + get :index + expect(current_user).to be_guest + end + end + + context 'when the flag is on' do + before { Rails.configuration.x.accept_courses_mooc_fi_tokens = true } + + it 'prefers a native Doorkeeper token' do + user = FactoryBot.create(:user) + allow(controller).to receive(:doorkeeper_token).and_return(double(resource_owner_id: user.id, acceptable?: true)) + expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) + get :index + expect(current_user).to eq(user) + end + + it 'resolves the user bound to the token subject' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + introspection_returns(result) + get :index + expect(current_user).to eq(user) + end + + it 'accepts a lower-case bearer scheme' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + request.headers['Authorization'] = "bearer #{bearer}" + introspection_returns(result) + get :index + expect(current_user).to eq(user) + end + + it 'resolves to Guest when courses.mooc.fi rejects the token' do + introspection_returns(nil) + get :index + expect(response).to have_http_status(:ok) + expect(current_user).to be_guest + end + + it 'answers 503 when courses.mooc.fi cannot check the token' do + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) + .and_raise(CoursesMoocFiTokenIntrospector::Unavailable, 'introspection answered HTTP 502') + expect(Rails.logger).to receive(:error).with(/introspection answered HTTP 502/) + get :index + expect(response).to have_http_status(:service_unavailable) + expect(response.body).to include('could not verify your login') + end + + it 'does not swallow a database error during the user lookup' do + introspection_returns(result) + allow(User).to receive(:find_by).and_raise(ActiveRecord::ConnectionNotEstablished) + expect { get :index }.to raise_error(ActiveRecord::ConnectionNotEstablished) + end + + { + 'no Authorization header' => nil, + 'a non-Bearer scheme' => 'Basic dXNlcjpwYXNz' + }.each do |description, header| + it "does not introspect with #{description}" do + request.headers['Authorization'] = header + expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) + get :index + expect(current_user).to be_guest + end + end + + context 'with elevated users' do + controller MoocTokenUselessController do + skip_authorization_check + def index + render json: { + administrator: current_user.administrator?, + manage_all: can?(:manage, :all), + teach: can?(:teach, Organization.find(params[:organization_id])) + } + end + end + + let(:organization) { FactoryBot.create(:organization) } + + before { introspection_returns(result) } + + it 'keeps an administrator an administrator' do + FactoryBot.create(:admin, courses_mooc_fi_user_id: sub) + get :index, params: { organization_id: organization.id } + expect(JSON.parse(response.body)).to include('administrator' => true, 'manage_all' => true) + end + + it 'keeps a teacher a teacher' do + teacher = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + Teachership.create!(user: teacher, organization: organization) + get :index, params: { organization_id: organization.id } + expect(JSON.parse(response.body)).to include('administrator' => false, 'teach' => true) + end + end + + context 'when no user is bound to the subject yet' do + it 'binds the user courses.mooc.fi reports as upstream_id' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: nil) + introspection_returns(result(upstream_id: user.id)) + get :index + expect(current_user).to eq(user) + expect(user.reload.courses_mooc_fi_user_id).to eq(sub) + end + + it 'resolves a user stored with an upper-case id through upstream_id' do + user = FactoryBot.create(:user) + User.where(id: user.id).update_all(courses_mooc_fi_user_id: sub.upcase) + introspection_returns(result(upstream_id: user.id)) + get :index + expect(current_user).to eq(user) + end + + it 'resolves to Guest when upstream_id matches no user' do + introspection_returns(result(upstream_id: 999_999)) + get :index + expect(current_user).to be_guest + end + + it 'refuses a user bound to a different subject' do + other_sub = '99999999-8888-7777-6666-555555555555' + user = FactoryBot.create(:user, courses_mooc_fi_user_id: other_sub) + introspection_returns(result(upstream_id: user.id)) + expect(Rails.logger).to receive(:warn).with(/bound to a different/) + get :index + expect(current_user).to be_guest + expect(user.reload.courses_mooc_fi_user_id).to eq(other_sub) + end + + it 'resolves the winner when a concurrent request binds the subject first' do + winner = FactoryBot.create(:user, courses_mooc_fi_user_id: nil) + loser = FactoryBot.create(:user, courses_mooc_fi_user_id: nil) + allow_any_instance_of(User).to receive(:update_column) do + User.where(id: winner.id).update_all(courses_mooc_fi_user_id: sub) + raise ActiveRecord::RecordNotUnique, 'duplicate key value violates unique constraint' + end + introspection_returns(result(upstream_id: loser.id)) + get :index + expect(current_user).to eq(winner) + end + end + end +end diff --git a/spec/controllers/api/v8/users/request_deletion_controller_spec.rb b/spec/controllers/api/v8/users/request_deletion_controller_spec.rb new file mode 100644 index 000000000..c189f5fa6 --- /dev/null +++ b/spec/controllers/api/v8/users/request_deletion_controller_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Api::V8::Users::RequestDeletionController, type: :controller do + let(:user) { FactoryBot.create(:user, courses_mooc_fi_user_id: SecureRandom.uuid) } + + around do |example| + original = Rails.configuration.x.accept_courses_mooc_fi_tokens + Rails.configuration.x.accept_courses_mooc_fi_tokens = true + example.run + Rails.configuration.x.accept_courses_mooc_fi_tokens = original + end + + it 'refuses a deletion request made with a courses.mooc.fi access token' do + allow(controller).to receive(:doorkeeper_token).and_return(nil) + request.headers['Authorization'] = 'Bearer sp331-access-token' + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return( + CoursesMoocFiTokenIntrospector::Result.new(sub: user.courses_mooc_fi_user_id) + ) + expect(UserMailer).not_to receive(:destroy_confirmation) + + post :create, params: { user_id: 'current' } + + expect(response).to have_http_status(403) + end +end diff --git a/spec/controllers/api/v8/users_controller_spec.rb b/spec/controllers/api/v8/users_controller_spec.rb index 8a0f8305f..66fc3b696 100644 --- a/spec/controllers/api/v8/users_controller_spec.rb +++ b/spec/controllers/api/v8/users_controller_spec.rb @@ -273,4 +273,65 @@ def do_update(old_password) expect(user.password_hash).to be_nil end end + + describe 'with a courses.mooc.fi access token' do + let(:token) { nil } + + around do |example| + original = Rails.configuration.x.accept_courses_mooc_fi_tokens + Rails.configuration.x.accept_courses_mooc_fi_tokens = true + example.run + Rails.configuration.x.accept_courses_mooc_fi_tokens = original + end + + def authenticate_with_token_as(token_user) + token_user.update!(courses_mooc_fi_user_id: SecureRandom.uuid) + request.headers['Authorization'] = 'Bearer sp331-access-token' + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return( + CoursesMoocFiTokenIntrospector::Result.new(sub: token_user.courses_mooc_fi_user_id) + ) + end + + it 'still shows the current user' do + authenticate_with_token_as(user) + get :show, params: { id: 'current' } + expect(response).to have_http_status(200) + expect(JSON.parse(response.body)['id']).to eq(user.id) + end + + it "refuses to change the user's own email" do + authenticate_with_token_as(user) + put :update, params: { id: 'current', user: { email: 'taken-over@example.com' } } + expect(response).to have_http_status(403) + expect(user.reload.email).not_to eq('taken-over@example.com') + end + + it "refuses an administrator changing another user's email" do + authenticate_with_token_as(admin) + put :update, params: { id: user.id, user: { email: 'taken-over@example.com' } } + expect(response).to have_http_status(403) + expect(user.reload.email).not_to eq('taken-over@example.com') + end + + it 'refuses to delete the user' do + authenticate_with_token_as(user) + delete :destroy, params: { id: user.id } + expect(response).to have_http_status(403) + expect(User.find_by(id: user.id)).not_to be_nil + end + + it 'refuses an administrator deleting a user' do + authenticate_with_token_as(admin) + delete :destroy, params: { id: user.id } + expect(response).to have_http_status(403) + expect(User.find_by(id: user.id)).not_to be_nil + end + + it 'refuses an administrator handing password management to courses.mooc.fi' do + authenticate_with_token_as(admin) + post :set_password_managed_by_courses_mooc_fi, params: { id: user.id, courses_mooc_fi_user_id: SecureRandom.uuid } + expect(response).to have_http_status(403) + expect(user.reload.password_managed_by_courses_mooc_fi).to eq(false) + end + end end diff --git a/spec/fixtures/courses_mooc_fi_introspection/README.md b/spec/fixtures/courses_mooc_fi_introspection/README.md new file mode 100644 index 000000000..6d79866e9 --- /dev/null +++ b/spec/fixtures/courses_mooc_fi_introspection/README.md @@ -0,0 +1,18 @@ +# courses.mooc.fi introspection fixtures + +Hand-mirrored copies of what secret-project-331 emits from +`POST /api/v0/main-frontend/oauth/introspect` (RFC 7662). They exist because +`CoursesMoocFiTokenIntrospector` reads that response by member name, and every other spec in +this repo stubs the response instead of producing it — so without these, a rename on either +side of the wire passes CI in both repos and logs every user out in production. + +Upstream definition: `services/headless-lms/server/src/domain/oauth/introspect_response.rs` +(struct `IntrospectResponse`). Its `tests::golden_serialized_shape` pins the same JSON on that +side; `active_response.json` is a copy of the literal in that test, so the two can be diffed by +eye. **The mirror is manual — when the upstream struct changes, update these files in the same +change.** The pairing is only as honest as that discipline: sp331's golden test fails when its +own output drifts, `courses_mooc_fi_introspection_contract_spec.rb` fails when these files stop +matching what the introspector reads, and neither can observe the other repo. + +`aud` is deliberately absent: every access token sp331 mints is created with `audience: None`, +so the member is never emitted. See the note in the introspector. diff --git a/spec/fixtures/courses_mooc_fi_introspection/active_response.json b/spec/fixtures/courses_mooc_fi_introspection/active_response.json new file mode 100644 index 000000000..86adf0419 --- /dev/null +++ b/spec/fixtures/courses_mooc_fi_introspection/active_response.json @@ -0,0 +1,14 @@ +{ + "active": true, + "scope": "exercise-services", + "client_id": "tmc-server-introspection-dev", + "username": "11111111-2222-3333-4444-555555555555", + "exp": 1767225600, + "iat": 1767222000, + "sub": "11111111-2222-3333-4444-555555555555", + "iss": "https://courses.mooc.fi/api/v0/main-frontend/oauth", + "jti": "123e4567-e89b-12d3-a456-426614174000", + "token_type": "Bearer", + "upstream_id": 42, + "client_bearer_allowed": true +} diff --git a/spec/fixtures/courses_mooc_fi_introspection/inactive_response.json b/spec/fixtures/courses_mooc_fi_introspection/inactive_response.json new file mode 100644 index 000000000..156c781ad --- /dev/null +++ b/spec/fixtures/courses_mooc_fi_introspection/inactive_response.json @@ -0,0 +1,3 @@ +{ + "active": false +} diff --git a/spec/fixtures/submission_uploads/empty.zip b/spec/fixtures/submission_uploads/empty.zip new file mode 100644 index 000000000..15cb0ecb3 Binary files /dev/null and b/spec/fixtures/submission_uploads/empty.zip differ diff --git a/spec/fixtures/submission_uploads/not_a_zip.txt b/spec/fixtures/submission_uploads/not_a_zip.txt new file mode 100644 index 000000000..65fc51e48 --- /dev/null +++ b/spec/fixtures/submission_uploads/not_a_zip.txt @@ -0,0 +1 @@ +this is not an archive diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2a08edd17..d7176eb60 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -270,6 +270,15 @@ expect(User.authenticate('root', 'ilikecookies')).to be_nil end + describe 'courses_mooc_fi_user_id' do + it 'is stored in lower case and found by any case' do + user = User.create!(login: 'manageduser', password: 'secret123', email: 'managed@example.com', + courses_mooc_fi_user_id: 'ABCDEF01-2345-6789-ABCD-EF0123456789') + expect(user.reload.courses_mooc_fi_user_id).to eq('abcdef01-2345-6789-abcd-ef0123456789') + expect(User.find_by(courses_mooc_fi_user_id: 'ABCDEF01-2345-6789-abcd-EF0123456789')).to eq(user) + end + end + describe 'courses_mooc_fi_profile_url' do it 'is nil when the user has no courses.mooc.fi id' do user = User.create!(login: 'localuser', password: 'secret123', email: 'localuser@example.com') diff --git a/spec/services/courses_mooc_fi_introspection_contract_spec.rb b/spec/services/courses_mooc_fi_introspection_contract_spec.rb new file mode 100644 index 000000000..e6067a890 --- /dev/null +++ b/spec/services/courses_mooc_fi_introspection_contract_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Drives the introspector with responses mirrored from secret-project-331's own output (see the +# fixtures' README), so a member rename on either side fails here rather than in production. +RSpec.describe 'courses.mooc.fi introspection response contract' do + include_context 'courses.mooc.fi introspection provider' + + # Absent, each of these must reject the token. + required_members = %w[active sub iss token_type client_bearer_allowed scope].freeze + # Read, but absence is tolerated; mapped to the Result attribute they fill. + optional_members = { 'exp' => :expires_at, 'upstream_id' => :upstream_id }.freeze + + def fixture(name) + JSON.parse(File.read(Rails.root.join('spec/fixtures/courses_mooc_fi_introspection', name))) + end + + let(:active_response) { fixture('active_response.json') } + let(:inactive_response) { fixture('inactive_response.json') } + let(:token) { 'sp331-access-token' } + + it 'names every member the introspector consumes, typed as it assumes' do + expect(active_response).to include( + 'active' => true, + 'sub' => a_string_matching(User::COURSES_MOOC_FI_USER_ID_FORMAT), + 'scope' => a_string_including('exercise-services'), + 'exp' => an_instance_of(Integer), + 'iss' => 'https://courses.mooc.fi/api/v0/main-frontend/oauth', + 'token_type' => 'Bearer', + 'upstream_id' => an_instance_of(Integer), + 'client_bearer_allowed' => true + ) + end + + # If this fails, sp331 gained audience support and the introspector should start checking it. + it 'carries no aud member' do + expect(active_response).not_to have_key('aud') + end + + it 'accepts the real active response' do + stub_provider(status: 200, body: active_response) + expect(CoursesMoocFiTokenIntrospector.introspect(token)).to have_attributes( + sub: active_response['sub'], + upstream_id: active_response['upstream_id'], + expires_at: Time.at(active_response['exp']) + ) + end + + it 'rejects the real inactive response, which carries nothing else' do + expect(inactive_response).to eq('active' => false) + stub_provider(status: 200, body: inactive_response) + expect(CoursesMoocFiTokenIntrospector.introspect(token)).to be_nil + end + + required_members.each do |member| + it "rejects the token when #{member} is missing" do + stub_provider(status: 200, body: active_response.except(member)) + expect(CoursesMoocFiTokenIntrospector.introspect(token)).to be_nil + end + end + + optional_members.each do |member, attribute| + it "accepts the token when #{member} is missing" do + stub_provider(status: 200, body: active_response.except(member)) + expect(CoursesMoocFiTokenIntrospector.introspect(token)).to have_attributes(attribute => nil) + end + end +end diff --git a/spec/services/courses_mooc_fi_token_introspector_spec.rb b/spec/services/courses_mooc_fi_token_introspector_spec.rb new file mode 100644 index 000000000..eae4b8b20 --- /dev/null +++ b/spec/services/courses_mooc_fi_token_introspector_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CoursesMoocFiTokenIntrospector do + include_context 'courses.mooc.fi introspection provider' + + let(:token) { 'sp331-access-token' } + let(:sub) { '11111111-2222-3333-4444-555555555555' } + let(:active_body) do + { + 'active' => true, + 'sub' => sub, + 'scope' => 'exercise-services other-scope', + 'exp' => (Time.now + 3600).to_i, + 'upstream_id' => 42, + 'iss' => 'https://courses.mooc.fi/api/v0/main-frontend/oauth', + 'token_type' => 'Bearer', + 'client_bearer_allowed' => true + } + end + + def introspect + described_class.introspect(token) + end + + describe '.introspect' do + it 'returns the subject and TMC id of an active token' do + stub_provider(status: 200, body: active_body) + expect(introspect).to have_attributes(sub: sub, upstream_id: 42, expires_at: Time.at(active_body['exp'])) + end + + it 'posts the token and client credentials as a form' do + allow(AppSecrets).to receive(:courses_mooc_fi_introspection_client_id).and_return('client-abc') + allow(AppSecrets).to receive(:courses_mooc_fi_introspection_secret).and_return('secret-xyz') + stub_provider(status: 200, body: active_body) + + introspect + + sent = provider_requests.last + expect(Rack::Utils.parse_query(sent.request_body)).to eq( + 'token' => token, 'client_id' => 'client-abc', 'client_secret' => 'secret-xyz' + ) + expect(sent.request_headers['Accept']).to eq('application/json') + end + + it 'derives the endpoint and issuer from courses_mooc_fi_base_url' do + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_base_url').and_return('http://project-331.local/') + provider.post('http://project-331.local/api/v0/main-frontend/oauth/introspect') do + [200, { 'Content-Type' => 'application/json' }, + active_body.merge('iss' => 'http://project-331.local/api/v0/main-frontend/oauth').to_json] + end + expect(introspect).not_to be_nil + end + + it 'lower-cases the subject' do + stub_provider(status: 200, body: active_body.merge('sub' => sub.upcase)) + expect(introspect.sub).to eq(sub) + end + + it 'accepts a lower-case token_type' do + stub_provider(status: 200, body: active_body.merge('token_type' => 'bearer')) + expect(introspect).not_to be_nil + end + + it 'returns nil for a blank token without asking the provider' do + expect(described_class.introspect('')).to be_nil + expect(provider_requests).to be_empty + end + + { + 'an inactive token' => { 'active' => false }, + 'another issuer' => { 'iss' => 'https://evil.example/oauth' }, + 'a DPoP-bound token' => { 'token_type' => 'DPoP' }, + 'a client not allowed bearer tokens' => { 'client_bearer_allowed' => false }, + 'a token without the exercise-services scope' => { 'scope' => 'other-scope' }, + 'a subject that is not a UUID' => { 'sub' => 'not-a-uuid' } + }.each do |description, overrides| + it "rejects #{description} with a warning" do + stub_provider(status: 200, body: active_body.merge(overrides)) + expect(Rails.logger).to receive(:warn).with(/courses.mooc.fi token rejected/) + expect(introspect).to be_nil + end + end + + context 'when courses.mooc.fi cannot answer' do + it 'raises Unavailable naming our credentials when it rejects them' do + stub_provider(status: 401, body: { 'error' => 'invalid_client' }) + expect { introspect }.to raise_error( + described_class::Unavailable, + /COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID and COURSES_MOOC_FI_INTROSPECTION_SECRET/ + ) + end + + { + 'a server error' => -> { stub_provider(status: 503, body: 'Service Unavailable', content_type: 'text/plain') }, + 'a timeout' => -> { stub_provider_error(Faraday::TimeoutError.new('execution expired')) }, + 'a refused connection' => -> { stub_provider_error(Faraday::ConnectionFailed.new('refused')) }, + 'malformed JSON' => -> { stub_provider(status: 200, body: '{"active": tr') }, + 'a body that is not an object' => -> { stub_provider(status: 200, body: '[]') } + }.each do |description, stub| + it "raises Unavailable on #{description}" do + instance_exec(&stub) + expect { introspect }.to raise_error(described_class::Unavailable) + end + end + + it 'raises Unavailable without asking when the client credentials are not configured' do + allow(AppSecrets).to receive(:courses_mooc_fi_introspection_secret).and_return('') + expect { introspect }.to raise_error(described_class::Unavailable, /not set/) + expect(provider_requests).to be_empty + end + + it 'raises Unavailable without asking when courses_mooc_fi_base_url is not configured' do + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_base_url').and_return(nil) + expect { introspect }.to raise_error(described_class::Unavailable, /not set/) + expect(provider_requests).to be_empty + end + end + end + + describe 'caching' do + it 'asks the provider once for an active token' do + stub_provider(status: 200, body: active_body) + 2.times { expect(introspect.sub).to eq(sub) } + expect(provider_requests.size).to eq(1) + end + + it 'remembers a rejection for REJECTION_CACHE_TTL' do + allow(cache).to receive(:write).and_call_original + allow(Rails.logger).to receive(:warn) + stub_provider(status: 200, body: active_body.merge('scope' => 'other-scope')) + 2.times { expect(introspect).to be_nil } + expect(provider_requests.size).to eq(1) + expect(cache).to have_received(:write).with(anything, anything, expires_in: described_class::REJECTION_CACHE_TTL) + end + + it 'never caches an unavailable answer' do + stub_provider(status: 401, body: { 'error' => 'invalid_client' }) + 2.times { expect { introspect }.to raise_error(described_class::Unavailable) } + expect(provider_requests.size).to eq(2) + end + + it 'caps the TTL at MAX_CACHE_TTL' do + allow(cache).to receive(:write).and_call_original + stub_provider(status: 200, body: active_body) + introspect + expect(cache).to have_received(:write).with(anything, anything, expires_in: described_class::MAX_CACHE_TTL) + end + + it "uses the token's remaining lifetime when shorter" do + allow(cache).to receive(:write).and_call_original + stub_provider(status: 200, body: active_body.merge('exp' => (Time.now + 60).to_i)) + introspect + expect(cache).to have_received(:write).with(anything, anything, expires_in: be_between(1, 60)) + end + + it 'does not cache an already-expired token' do + allow(cache).to receive(:write).and_call_original + stub_provider(status: 200, body: active_body.merge('exp' => (Time.now - 1).to_i)) + introspect + expect(cache).not_to have_received(:write) + end + end +end diff --git a/spec/support/courses_mooc_fi_introspection.rb b/spec/support/courses_mooc_fi_introspection.rb new file mode 100644 index 000000000..86a7dfc1f --- /dev/null +++ b/spec/support/courses_mooc_fi_introspection.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Points CoursesMoocFiTokenIntrospector at a Faraday test adapter standing in for courses.mooc.fi, +# keeping the introspector's own middleware so request encoding and JSON parsing are exercised. +RSpec.shared_context 'courses.mooc.fi introspection provider' do + let(:introspection_path) { '/api/v0/main-frontend/oauth/introspect' } + let(:provider) { Faraday::Adapter::Test::Stubs.new } + let(:cache) { ActiveSupport::Cache::MemoryStore.new } + # Every request the provider received, as Faraday::Env. + let(:provider_requests) { [] } + + before do + allow(SiteSetting).to receive(:value).and_call_original + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_base_url').and_return('https://courses.mooc.fi') + allow(Rails).to receive(:cache).and_return(cache) + + build_connection = Faraday.method(:new) + allow(Faraday).to receive(:new) do |*args, **options, &configure| + build_connection.call(*args, **options) do |f| + configure&.call(f) + f.adapter :test, provider + end + end + end + + def stub_provider(status:, body:, content_type: 'application/json') + provider.post(introspection_path) do |env| + provider_requests << env + [status, { 'Content-Type' => content_type }, body.is_a?(String) ? body : body.to_json] + end + end + + def stub_provider_error(error) + provider.post(introspection_path) do |env| + provider_requests << env + raise error + end + end +end