From df4731c740f50f544d516d9896a68bd4ea192e42 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 22 Jul 2026 16:32:25 +0300 Subject: [PATCH 1/6] Add courses.mooc.fi OAuth token introspection to API v8 authentication --- app/controllers/api/v8/base_controller.rb | 95 ++++++ .../courses_mooc_fi_token_introspector.rb | 213 ++++++++++++ config/application.rb | 5 + config/secrets.yml | 12 + config/site.defaults.yml | 5 + .../api/v8/courses_mooc_fi_token_auth_spec.rb | 215 ++++++++++++ .../courses_mooc_fi_introspection/README.md | 18 + .../active_response.json | 14 + .../inactive_response.json | 3 + ...ses_mooc_fi_introspection_contract_spec.rb | 126 +++++++ ...courses_mooc_fi_token_introspector_spec.rb | 318 ++++++++++++++++++ 11 files changed, 1024 insertions(+) create mode 100644 app/services/courses_mooc_fi_token_introspector.rb create mode 100644 spec/controllers/api/v8/courses_mooc_fi_token_auth_spec.rb create mode 100644 spec/fixtures/courses_mooc_fi_introspection/README.md create mode 100644 spec/fixtures/courses_mooc_fi_introspection/active_response.json create mode 100644 spec/fixtures/courses_mooc_fi_introspection/inactive_response.json create mode 100644 spec/services/courses_mooc_fi_introspection_contract_spec.rb create mode 100644 spec/services/courses_mooc_fi_token_introspector_spec.rb diff --git a/app/controllers/api/v8/base_controller.rb b/app/controllers/api/v8/base_controller.rb index a83f1c1c8..ff2818873 100644 --- a/app/controllers/api/v8/base_controller.rb +++ b/app/controllers/api/v8/base_controller.rb @@ -34,18 +34,113 @@ def present(hash) end end + # The exact UUID format the User model enforces on courses_mooc_fi_user_id (see + # app/models/user.rb). Duplicated here so the introspected subject is shape-checked before + # any lookup or the update_column backfill, which bypasses that load-bearing model validation. + COURSES_MOOC_FI_USER_ID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/ + private def authenticate_user! return @current_user if @current_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 && (bearer = bearer_token).present? + @current_user ||= user_from_courses_mooc_fi_token(bearer) end @current_user ||= user_from_session || Guest.new end attr_reader :current_user + # Additive, feature-flagged auth path (Rails.configuration.x.accept_courses_mooc_fi_tokens, + # default off). Only reached when there is no native Doorkeeper token. Treats the bearer as + # a courses.mooc.fi (secret-project-331) OAuth token, validates it via RFC 7662 + # introspection, and maps it to a local user. Fails closed to nil (caller resolves Guest) + # on any problem; never raises. + def user_from_courses_mooc_fi_token(token) + result = CoursesMoocFiTokenIntrospector.introspect(token) + return nil unless result + + unless result.scope?('exercise-services') + Rails.logger.warn('courses.mooc.fi token rejected: missing exercise-services scope') + return nil + end + + # The subject is about to be used as courses_mooc_fi_user_id, both for the find_by below + # and (on a cache miss) for the update_column backfill, which bypasses the model's + # load-bearing UUID-format validation. Shape-check it once here so a malformed subject can + # neither be looked up nor persisted. Fail closed on mismatch. + unless COURSES_MOOC_FI_USER_ID_FORMAT.match?(result.sub) + Rails.logger.warn('courses.mooc.fi token rejected: subject is not a valid UUID') + return nil + end + + user = User.find_by(courses_mooc_fi_user_id: result.sub) + user ||= backfill_from_upstream_id(result) + return nil unless user + + # Decision 5 (widened 2026-07-23): introspected tokens must never resolve to an elevated + # user. Originally admins-only; now also blocks anyone holding any teachership or + # assistantship, because those grant real CanCan abilities (manage exercises/deadlines, + # read others' submissions). Elevated users keep using native tmc tokens; fail closed to + # Guest here. + reason = elevated_user_reason(user) + if reason + Rails.logger.warn("courses.mooc.fi token resolved to #{reason} user #{user.id}; refusing introspected auth (elevated users must use native tmc tokens)") + return nil + end + + user + rescue => e + Rails.logger.warn("courses.mooc.fi token authentication error: #{e.class}: #{e.message}") + nil + end + + # nil when the user holds no elevated role; otherwise a short reason naming the highest + # concern (administrator > teacher > assistant), used only for the warn log. Uses efficient + # existence checks rather than loading and iterating every organization/course. + def elevated_user_reason(user) + return 'administrator' if user.administrator? + return 'teacher' if Teachership.exists?(user_id: user.id) + return 'assistant' if Assistantship.exists?(user_id: user.id) + nil + end + + # No user is mapped to this token's subject yet, but the introspection response carries the + # TMC integer id (upstream_id). Look the user up by it and backfill the UUID so later + # requests resolve directly. Guarded against the unique-index race and against clobbering a + # user already bound to a different subject. + def backfill_from_upstream_id(result) + upstream_id = result.upstream_id + return nil if upstream_id.blank? + + user = User.find_by(id: upstream_id) + return nil unless user + + if user.courses_mooc_fi_user_id.blank? + begin + user.update_column(:courses_mooc_fi_user_id, result.sub) + rescue ActiveRecord::RecordNotUnique + # Another request backfilled the same subject first. Trust the authoritative mapping. + user = User.find_by(courses_mooc_fi_user_id: result.sub) + end + elsif user.courses_mooc_fi_user_id != result.sub + # upstream_id points at a user already bound to a different subject. Do not override. + Rails.logger.warn("courses.mooc.fi upstream_id #{upstream_id} maps to user #{user.id} already bound to a different courses_mooc_fi_user_id; refusing") + return nil + end + + user + end + + def bearer_token + auth = request.authorization + return nil unless auth + match = auth.match(/\ABearer[ ]+(.+)\z/i) + match && match[1] + end + def errors_json(messages) { errors: [*messages] } 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..9bc2037c9 --- /dev/null +++ b/app/services/courses_mooc_fi_token_introspector.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'digest' + +# Validates courses.mooc.fi (secret-project-331) OAuth2 access tokens against the +# provider's RFC 7662 token introspection endpoint. +# +# This is the tmc-server side of the auth migration: newer tmc-vscode versions log +# in once at courses.mooc.fi and send that bearer token to both backends. tmc-server +# cannot validate such a token locally (it is opaque and lives in the sp331 +# database), so it asks the provider whether the token is active and who it belongs +# to. +# +# Fails closed: any error at all (missing config, network failure, non-200 status, +# malformed JSON, an inactive token, a response without a subject) yields nil, so +# the caller falls back to Guest. Only positive results are cached, and never longer +# than the token's own remaining lifetime nor MAX_CACHE_TTL. Failures are never +# cached. +class CoursesMoocFiTokenIntrospector + # Never trust a cached positive result longer than this, even for long-lived + # tokens (seconds). + MAX_CACHE_TTL = 300 + CACHE_NAMESPACE = 'courses_mooc_fi_introspection' + + # A validated, active introspection response. Marshalable so it round-trips + # through Rails.cache. + Result = Struct.new(:sub, :scopes, :upstream_id, :expires_at, keyword_init: true) do + def scope?(name) + scopes.include?(name) + end + end + + # Returns a Result for an active token, or nil on any failure / inactive token. + def self.introspect(token) + new.introspect(token) + end + + def introspect(token) + return nil if token.blank? + + cached = read_cache(token) + return cached if cached + + body = request_introspection(token) + return nil unless body.is_a?(Hash) + return nil unless body['active'] == true + return nil unless expected_issuer?(body) + return nil unless bearer_token_type?(body) + return nil unless client_bearer_allowed?(body) + + result = build_result(body) + return nil if result.nil? + + write_cache(token, result) + result + rescue => e + # Fail closed on anything unexpected (network, JSON parse, etc.). + Rails.logger.warn("courses.mooc.fi token introspection failed: #{e.class}: #{e.message}") + nil + end + + private + def request_introspection(token) + url = SiteSetting.value('courses_mooc_fi_introspection_url') + client_id = Rails.application.secrets.courses_mooc_fi_introspection_client_id + client_secret = Rails.application.secrets.courses_mooc_fi_introspection_secret + + # A flag-on-but-unconfigured deploy (blank URL or missing client credentials) would otherwise + # silently fail closed to Guest with no clue why. Emit one distinct warn so the misconfig is + # diagnosable; still fail closed. Distinct from the network-failure warn in #introspect. + if url.blank? || client_id.blank? || client_secret.blank? + Rails.logger.warn('courses.mooc.fi token introspection is not configured (missing URL or client credentials); refusing to introspect') + return nil + end + + # Mirror the Faraday idiom used elsewhere for courses.mooc.fi calls (see + # User#authenticate_via_courses_mooc_fi), with tight timeouts so a hung + # provider can never stall an authenticated request. RFC 7662 + # client_secret_post: client credentials go in the form body. + conn = Faraday.new(request: { open_timeout: 2, timeout: 5 }) do |f| + f.request :url_encoded + f.response :json + end + + response = conn.post(url) do |req| + req.headers['Accept'] = 'application/json' + req.body = { + token: token, + client_id: client_id, + client_secret: client_secret + } + end + + return nil if rejected_our_credentials?(response) + return nil unless response.status == 200 + response.body + end + + # A wrong-but-non-blank COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID or secret is + # indistinguishable from a bad user token at the call site — both just fail closed to Guest — + # so without this it presents as "every user is logged out" with nothing naming the cause. + # The provider answers 401 invalid_client for rejected client credentials (RFC 7662 §2.3), + # separately from the 200 `active: false` it uses for an inactive token, so the two can be + # told apart. Log at error: this is our own misconfiguration, not a user's problem, and it + # affects every request rather than one. + def rejected_our_credentials?(response) + return false unless response.status == 401 + + error = response.body.is_a?(Hash) ? response.body['error'] : nil + Rails.logger.error("courses.mooc.fi rejected tmc-server's own introspection client credentials (HTTP 401, error #{error.inspect}); check COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID and COURSES_MOOC_FI_INTROSPECTION_SECRET. No user can authenticate via courses.mooc.fi until this is fixed.") + true + end + + # The provider stamps every active response with `iss`, its OAuth issuer identifier + # ("/api/v0/main-frontend/oauth"). Verify it so a response cannot be honoured as + # though it came from the configured provider when it did not — a misdirected + # courses_mooc_fi_introspection_url, or a proxy answering in its place. + # + # The expected value is derived from that same setting rather than configured separately: + # sp331 serves this endpoint at "/introspect", so the issuer is the configured URL + # minus that suffix. A second setting would only add a way for the two to disagree, and an + # expected-issuer knob nobody sets would verify nothing. + # + # `aud` is deliberately NOT verified: sp331 creates every access token with a null audience, + # so the member is never emitted (see spec/fixtures/courses_mooc_fi_introspection/). There is + # nothing to compare against, and requiring it would reject every token. Audience would only + # start to matter if tokens were minted for a specific resource server; today the + # exercise-services scope check at the call site is what limits what a token can be used for. + def expected_issuer?(body) + url = SiteSetting.value('courses_mooc_fi_introspection_url').to_s + expected = url[%r{\A(.*)/introspect/?\z}, 1] + + if expected.blank? + Rails.logger.error("courses.mooc.fi introspection URL #{url.inspect} does not end in /introspect, so the expected issuer cannot be derived; refusing to introspect") + return false + end + + return true if body['iss'] == expected + + Rails.logger.warn("courses.mooc.fi token rejected: iss #{body['iss'].inspect} is not #{expected.inspect}") + false + end + + # The provider mints both plain Bearer and sender-constrained (DPoP) access tokens and reports + # which via the introspection response's `token_type` ("Bearer" / "DPoP"). A DPoP-bound token + # proves nothing about whoever presents it as a plain bearer, and sp331's own client-facing API + # refuses one for exactly that reason (see secret-project-331 + # server/src/domain/exercise_services/token.rs, which requires token_type == Bearer). tmc-server + # only ever reads tokens out of an `Authorization: Bearer` header, so mirror that rule instead of + # being the weaker of the two backends. + # + # A missing/unrecognised token_type is treated as not-Bearer: this class fails closed, and the + # provider always sends the claim for an active token. + def bearer_token_type?(body) + token_type = body['token_type'] + return true if token_type.to_s.casecmp('bearer').zero? + + Rails.logger.warn("courses.mooc.fi token rejected: token_type #{token_type.inspect} is not Bearer") + false + end + + # The provider's introspection response carries a non-standard `client_bearer_allowed` member: + # whether the client the token was issued to may use plain Bearer tokens (sp331's own + # client-facing extractor refuses the token otherwise, requiring client.allows_bearer()). It is + # sent only to confidential callers (tmc-server always qualifies) and is OMITTED, not `false`, + # when withheld — so absence means "no assertion", not "allowed", and must fail closed too. + # Hence `== true` rather than Ruby truthiness. + def client_bearer_allowed?(body) + return true if body['client_bearer_allowed'] == true + + Rails.logger.warn("courses.mooc.fi token rejected: client_bearer_allowed #{body['client_bearer_allowed'].inspect} is not true") + false + end + + def build_result(body) + sub = body['sub'] + return nil if sub.blank? + + scopes = body['scope'].to_s.split(' ') + exp = body['exp'] + expires_at = exp.is_a?(Numeric) ? Time.at(exp) : nil + + Result.new( + sub: sub, + scopes: scopes, + upstream_id: body['upstream_id'], + expires_at: expires_at + ) + end + + def cache_key(token) + "#{CACHE_NAMESPACE}:#{Digest::SHA256.hexdigest(token)}" + end + + def read_cache(token) + Rails.cache.read(cache_key(token)) + end + + def write_cache(token, result) + ttl = cache_ttl(result) + return if ttl.nil? || ttl <= 0 + Rails.cache.write(cache_key(token), result, expires_in: ttl) + end + + # TTL = min(exp - now, MAX_CACHE_TTL). A token that carries no exp is still + # cached, but only up to MAX_CACHE_TTL. An already-expired token is not cached. + def cache_ttl(result) + return MAX_CACHE_TTL if result.expires_at.nil? + remaining = (result.expires_at - Time.now).floor + return nil if remaining <= 0 + [remaining, MAX_CACHE_TTL].min + end +end diff --git a/config/application.rb b/config/application.rb index 6c61f3e3a..e9d407a15 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,11 @@ class Application < Rails::Application config.relative_url_root = SiteSetting.value('base_path') + # Feature flag (default off): accept courses.mooc.fi (secret-project-331) OAuth tokens on the + # API v8 auth path via RFC 7662 introspection. See Api::V8::BaseController#authenticate_user! + # and CoursesMoocFiTokenIntrospector. Additive: with this off every request path is unchanged. + 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/secrets.yml b/config/secrets.yml index 804d52eb3..5418a06a4 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -42,6 +42,8 @@ development: 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" %> test: secret_key_base: fdc6fb714d1447ce6ab48e3f216e5419b567dcfbc9a713273f8f193c3f13807105136de778516770d451485a8fc31e8d58eae6259e94e2a4b53f547329bb1486 @@ -75,6 +77,14 @@ test: wvTexLwtU6i38xTpGMeTsQVx -----END PRIVATE KEY----- tmc_server_secret_for_communicating_to_secret_project: <%= ENV["TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT"] || "Zm9yIGxvY2FsIGRldmVsb3BtZW50IG9ubHksIGludGVudGlvbmFsbHkgcHVibGlj" %> + # Deliberately the same client as the development default. The test suite never contacts a real + # introspection endpoint (courses_mooc_fi_introspection_url is blank in config/site.defaults.yml, + # and the specs stub Faraday / the introspector outright), so this value is only ever echoed back + # in assertions. secret-project-331 seeds exactly one introspection client for dev/CI, + # `tmc-server-introspection-dev` (see its seed_oauth_clients.rs); a distinct `-test` id would be a + # client that exists nowhere. + 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 +92,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"] %> + courses_mooc_fi_introspection_secret: <%= ENV["COURSES_MOOC_FI_INTROSPECTION_SECRET"] %> diff --git a/config/site.defaults.yml b/config/site.defaults.yml index 36ea5a787..ad19b8c6c 100644 --- a/config/site.defaults.yml +++ b/config/site.defaults.yml @@ -143,3 +143,8 @@ teacher_manual_url: http://testmycode.github.io/tmc-server/usermanual/ courses_mooc_fi_auth_url: courses_mooc_fi_update_password_url: courses_mooc_fi_create_user_url: + +# RFC 7662 introspection endpoint for validating courses.mooc.fi OAuth tokens. Only used when the +# ACCEPT_COURSES_MOOC_FI_TOKENS flag is on. Set per deployment, e.g. +# https://courses.mooc.fi/api/v0/main-frontend/oauth/introspect +courses_mooc_fi_introspection_url: 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..f30708038 --- /dev/null +++ b/spec/controllers/api/v8/courses_mooc_fi_token_auth_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Exercises the additive, feature-flagged courses.mooc.fi (secret-project-331) token +# introspection branch in Api::V8::BaseController#authenticate_user!. Mirrors the +# UselessController pattern from base_controller_spec.rb. +class MoocTokenUselessController < Api::V8::BaseController +end + +RSpec.describe Api::V8::BaseController, type: :controller do + controller MoocTokenUselessController do + skip_authorization_check # not testing cancan here + 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(scopes: ['exercise-services'], upstream_id: nil, subject_id: sub) + CoursesMoocFiTokenIntrospector::Result.new( + sub: subject_id, scopes: scopes, upstream_id: upstream_id, expires_at: Time.now + 3600 + ) + end + + # Save/restore the flag so tests never leak global state. + 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 + # No native Doorkeeper token in these tests unless a context overrides it. + allow(controller).to receive(:doorkeeper_token).and_return(nil) + request.headers['Authorization'] = "Bearer #{bearer}" + end + + context 'when the flag is off (default behaviour, regression)' do + before { Rails.configuration.x.accept_courses_mooc_fi_tokens = false } + + it 'never introspects and resolves to Guest' do + expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) + get :index + expect(current_user).to be_guest + end + + it 'does not even read the bearer token when the flag is off, despite a bearer header' do + # The flag is the first operand of the && guard, so short-circuit evaluation must skip + # bearer_token (and therefore introspection) entirely even though an unknown bearer is + # present. Pins the flag-off path's evaluation-order invariant. + expect(controller).not_to receive(:bearer_token) + expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) + get :index + expect(current_user).to be_guest + end + + it 'still authenticates 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)) + get :index + expect(current_user.id).to eq(user.id) + end + end + + context 'when the flag is on' do + before { Rails.configuration.x.accept_courses_mooc_fi_tokens = true } + + it 'still prefers a native Doorkeeper token over introspection' 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.id).to eq(user.id) + end + + it 'resolves the mapped user for a valid introspected token' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).with(bearer).and_return(result) + get :index + expect(current_user.id).to eq(user.id) + end + + it 'resolves to Guest when the exercise-services scope is missing' do + FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result(scopes: ['other-scope'])) + get :index + expect(current_user).to be_guest + end + + it 'resolves to Guest when introspection fails' do + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(nil) + get :index + expect(current_user).to be_guest + end + + it 'resolves to Guest and warns when the subject is not a valid UUID' do + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) + .and_return(result(subject_id: 'not-a-uuid')) + expect(Rails.logger).to receive(:warn).with(/not a valid UUID/) + get :index + expect(current_user).to be_guest + end + + it 'resolves to Guest and warns when the mapped user is an administrator' do + FactoryBot.create(:admin, courses_mooc_fi_user_id: sub) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result) + expect(Rails.logger).to receive(:warn).with(/administrator/) + get :index + expect(current_user).to be_guest + end + + it 'resolves to Guest and warns when the mapped user holds a teachership' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + Teachership.create!(user: user, organization: FactoryBot.create(:organization)) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result) + expect(Rails.logger).to receive(:warn).with(/teacher/) + get :index + expect(current_user).to be_guest + end + + it 'resolves to Guest and warns when the mapped user holds an assistantship' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + Assistantship.create!(user: user, course: FactoryBot.create(:course)) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result) + expect(Rails.logger).to receive(:warn).with(/assistant/) + get :index + expect(current_user).to be_guest + end + + context 'upstream_id fallback + backfill' do + it 'resolves via upstream_id and backfills courses_mooc_fi_user_id' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: nil) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) + .and_return(result(upstream_id: user.id)) + get :index + expect(current_user.id).to eq(user.id) + expect(user.reload.courses_mooc_fi_user_id).to eq(sub) + end + + it 'does not authenticate an administrator via the upstream_id fallback' do + admin = FactoryBot.create(:admin, courses_mooc_fi_user_id: nil) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) + .and_return(result(upstream_id: admin.id)) + get :index + expect(current_user).to be_guest + end + + it 'resolves to Guest when neither the subject nor upstream_id match a user' do + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) + .and_return(result(upstream_id: 999_999)) + get :index + expect(current_user).to be_guest + end + + it 'refuses when upstream_id maps to a user already bound to a different subject' do + other_sub = '99999999-8888-7777-6666-555555555555' + user = FactoryBot.create(:user, courses_mooc_fi_user_id: other_sub) + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) + .and_return(result(upstream_id: user.id)) + expect(Rails.logger).to receive(:warn).with(/already 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 'trusts the winner of the backfill race when the unique index rejects the write' do + # Two concurrent requests for the same subject: this one loses the unique-index race. + # The rescue must re-read the authoritative mapping rather than fail to Guest. + winner = FactoryBot.create(:user, courses_mooc_fi_user_id: nil) + loser = FactoryBot.create(:user, courses_mooc_fi_user_id: nil) + # Simulate the racing request committing first, then our write blowing up. update_all + # rather than update_column, which is the stubbed method. + 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 + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) + .and_return(result(upstream_id: loser.id)) + + get :index + + expect(current_user.id).to eq(winner.id) + end + end + + context 'bearer header parsing' do + it 'does not introspect when there is no Authorization header' do + request.headers['Authorization'] = nil + expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) + get :index + expect(current_user).to be_guest + end + + it 'does not introspect a non-Bearer authorization scheme' do + request.headers['Authorization'] = 'Basic dXNlcjpwYXNz' + expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) + get :index + expect(current_user).to be_guest + end + + it 'accepts a lower-case bearer scheme and passes the raw token through' do + user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) + request.headers['Authorization'] = "bearer #{bearer}" + allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).with(bearer).and_return(result) + get :index + expect(current_user.id).to eq(user.id) + end + 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/services/courses_mooc_fi_introspection_contract_spec.rb b/spec/services/courses_mooc_fi_introspection_contract_spec.rb new file mode 100644 index 000000000..317c90b58 --- /dev/null +++ b/spec/services/courses_mooc_fi_introspection_contract_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Members whose absence must reject the token: without them nothing has been asserted about +# who the token belongs to or whether it may be presented as a bearer credential. +REQUIRED_INTROSPECTION_MEMBERS = %w[active sub iss token_type client_bearer_allowed].freeze + +# Members the introspector reads but tolerates the absence of, each for a documented reason. +# They still belong in the contract: a rename breaks the read just as badly, it just degrades +# quietly instead of rejecting. +OPTIONAL_INTROSPECTION_MEMBERS = %w[scope exp upstream_id].freeze + +# Cross-repo wire contract for the courses.mooc.fi (secret-project-331) introspection response. +# +# Every other spec touching CoursesMoocFiTokenIntrospector builds its response body inline, so +# the member names it reads are only ever compared against themselves. This one drives the +# introspector with a committed fixture mirrored from sp331's own output +# (spec/fixtures/courses_mooc_fi_introspection/, see the README there for provenance) and pins +# the members consumed, so a rename on either side fails here instead of in production. +RSpec.describe 'courses.mooc.fi introspection response contract' do + 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' } + # The introspector derives the issuer it requires from this URL, so the fixture's `iss` has to + # be the one this endpoint implies. + let(:introspection_url) { 'https://courses.mooc.fi/api/v0/main-frontend/oauth/introspect' } + + before do + allow(SiteSetting).to receive(:value).and_call_original + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url').and_return(introspection_url) + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + end + + def stub_provider(status:, body:) + response = instance_double(Faraday::Response, status: status, body: body) + conn = instance_double(Faraday::Connection) + allow(conn).to receive(:post).and_return(response) + allow(Faraday).to receive(:new).and_return(conn) + conn + end + + it 'names every member the introspector consumes' do + expect(active_response.keys).to include( + *REQUIRED_INTROSPECTION_MEMBERS, *OPTIONAL_INTROSPECTION_MEMBERS + ) + end + + it 'types those members as the introspector assumes' do + expect(active_response['active']).to be(true) + expect(active_response['sub']).to be_a(String) + expect(active_response['scope']).to be_a(String) # space-separated, not an array + expect(active_response['exp']).to be_a(Numeric) # Unix seconds, not an ISO 8601 string + expect(active_response['iss']).to be_a(String) + expect(active_response['token_type']).to eq('Bearer') + expect(active_response['upstream_id']).to be_a(Integer) + expect(active_response['client_bearer_allowed']).to be(true) + end + + # sp331 mints every access token with `audience: None`, so verifying `aud` is impossible. If + # this starts failing, the provider gained audience support and the introspector's recorded + # reasoning about not verifying it needs revisiting. + it 'carries no aud member' do + expect(active_response).not_to have_key('aud') + end + + it 'accepts the real active response end to end' do + stub_provider(status: 200, body: active_response) + + result = CoursesMoocFiTokenIntrospector.introspect(token) + + expect(result).not_to be_nil + expect(result.sub).to eq(active_response['sub']) + expect(result).to be_scope('exercise-services') + expect(result.upstream_id).to eq(active_response['upstream_id']) + expect(result.expires_at).to eq(Time.at(active_response['exp'])) + end + + it 'rejects the real inactive response' do + stub_provider(status: 200, body: inactive_response) + expect(CoursesMoocFiTokenIntrospector.introspect(token)).to be_nil + end + + # The negative shape carries no metadata, so nothing downstream can read a subject out of a + # rejected token. + it 'keeps the inactive response free of metadata' do + expect(inactive_response.keys).to eq(['active']) + expect(inactive_response['active']).to be(false) + end + + REQUIRED_INTROSPECTION_MEMBERS.each do |member| + it "fails closed when the provider stops sending #{member}" do + stub_provider(status: 200, body: active_response.except(member)) + expect(CoursesMoocFiTokenIntrospector.introspect(token)).to be_nil + end + end + + describe 'members that are optional by design' do + # No scope member means no scopes, which closes the caller's exercise-services gate anyway — + # so this degrades to unauthorized rather than to unauthenticated. + it 'yields no scopes when scope is absent' do + stub_provider(status: 200, body: active_response.except('scope')) + result = CoursesMoocFiTokenIntrospector.introspect(token) + expect(result.scopes).to be_empty + expect(result).not_to be_scope('exercise-services') + end + + # A token without exp is still usable; the cache just falls back to MAX_CACHE_TTL. + it 'yields no expiry when exp is absent' do + stub_provider(status: 200, body: active_response.except('exp')) + expect(CoursesMoocFiTokenIntrospector.introspect(token).expires_at).to be_nil + end + + # A legitimately absent value: the token owner has no legacy TMC account to link. + it 'yields no upstream_id when upstream_id is absent' do + stub_provider(status: 200, body: active_response.except('upstream_id')) + result = CoursesMoocFiTokenIntrospector.introspect(token) + expect(result).not_to be_nil + expect(result.upstream_id).to be_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..279ca87ff --- /dev/null +++ b/spec/services/courses_mooc_fi_token_introspector_spec.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CoursesMoocFiTokenIntrospector do + let(:token) { 'sp331-access-token' } + let(:introspection_url) { 'https://courses.mooc.fi/api/v0/main-frontend/oauth/introspect' } + let(:sub) { '11111111-2222-3333-4444-555555555555' } + let(:cache) { ActiveSupport::Cache::MemoryStore.new } + + # A fresh, active introspection response. exp far in the future so caching is exercised. + 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 + + before do + allow(SiteSetting).to receive(:value).and_call_original + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url').and_return(introspection_url) + allow(Rails).to receive(:cache).and_return(cache) + end + + # Captures what the introspector's post-block builds, so a regression in the request-building + # block (form body / Accept header) fails a spec instead of passing green. + let(:sent_headers) { {} } + let(:sent_body) { {} } + + # Stand-in for the Faraday::Request yielded to the post block. A plain double (not an + # instance_double) keeps this robust across Faraday versions; it only needs #headers (a mutable + # hash) and #body=. #headers returns the same captured hash so header writes are observable. + let(:request_double) do + req = double('Faraday::Request') + allow(req).to receive(:headers).and_return(sent_headers) + allow(req).to receive(:body=) { |value| sent_body.replace(value) } + req + end + + # Stubs Faraday so no real HTTP happens, but still RUNS the request-building block against + # request_double so its body/header setup is exercised. Returns the connection double. + def stub_faraday_response(status:, body:) + response = instance_double(Faraday::Response, status: status, body: body) + conn = instance_double(Faraday::Connection) + allow(conn).to receive(:post) do |_url, &blk| + blk&.call(request_double) + response + end + allow(Faraday).to receive(:new).and_return(conn) + conn + end + + def stub_faraday_raise(error) + conn = instance_double(Faraday::Connection) + allow(conn).to receive(:post).and_raise(error) + allow(Faraday).to receive(:new).and_return(conn) + conn + end + + describe '.introspect' do + it 'returns a result for an active token' do + stub_faraday_response(status: 200, body: active_body) + result = described_class.introspect(token) + + expect(result).not_to be_nil + expect(result.sub).to eq(sub) + expect(result.scopes).to contain_exactly('exercise-services', 'other-scope') + expect(result).to be_scope('exercise-services') + expect(result.upstream_id).to eq(42) + end + + it 'sends the token, both client credentials, and the Accept header in the request' do + allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_client_id).and_return('client-abc') + allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_secret).and_return('secret-xyz') + stub_faraday_response(status: 200, body: active_body) + + described_class.introspect(token) + + expect(sent_body).to include( + token: token, + client_id: Rails.application.secrets.courses_mooc_fi_introspection_client_id, + client_secret: Rails.application.secrets.courses_mooc_fi_introspection_secret + ) + expect(sent_body[:client_id]).to eq('client-abc') + expect(sent_body[:client_secret]).to eq('secret-xyz') + expect(sent_headers['Accept']).to eq('application/json') + end + + it 'returns nil and warns when the introspection is not configured (missing secrets)' do + allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_client_id).and_return('') + allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_secret).and_return('') + expect(Rails.logger).to receive(:warn).with(/not configured/) + expect(Faraday).not_to receive(:new) + expect(described_class.introspect(token)).to be_nil + end + + it 'returns nil for a blank token without calling the provider' do + conn = stub_faraday_response(status: 200, body: active_body) + expect(described_class.introspect('')).to be_nil + expect(conn).not_to have_received(:post) + end + + it 'returns nil when the introspection URL is not configured' do + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url').and_return(nil) + expect(Faraday).not_to receive(:new) + expect(described_class.introspect(token)).to be_nil + end + + it 'returns nil when the token is inactive (active: false)' do + stub_faraday_response(status: 200, body: { 'active' => false }) + expect(described_class.introspect(token)).to be_nil + end + + it 'returns nil on a non-200 status' do + stub_faraday_response(status: 500, body: active_body) + expect(described_class.introspect(token)).to be_nil + end + + # Our own credentials being rejected is a deployment fault affecting every user, not a + # statement about this token, so it must be distinguishable from a 200 `active: false`. + it 'logs distinctly at error when the provider rejects our client credentials' do + stub_faraday_response(status: 401, body: { 'error' => 'invalid_client' }) + expect(Rails.logger).to receive(:error).with(/rejected tmc-server's own introspection client credentials/) + expect(described_class.introspect(token)).to be_nil + end + + it 'names the misconfigured environment variables in that log' do + stub_faraday_response(status: 401, body: { 'error' => 'invalid_client' }) + expect(Rails.logger).to receive(:error).with( + /COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID.*COURSES_MOOC_FI_INTROSPECTION_SECRET/ + ) + described_class.introspect(token) + end + + it 'still reports a credential rejection with an unparseable body' do + stub_faraday_response(status: 401, body: 'Unauthorized') + expect(Rails.logger).to receive(:error).with(/introspection client credentials/) + expect(described_class.introspect(token)).to be_nil + end + + it 'does not log a credential rejection for an inactive token' do + stub_faraday_response(status: 200, body: { 'active' => false }) + expect(Rails.logger).not_to receive(:error) + expect(described_class.introspect(token)).to be_nil + end + + it 'does not log a credential rejection for an unrelated server error' do + stub_faraday_response(status: 500, body: active_body) + expect(Rails.logger).not_to receive(:error) + expect(described_class.introspect(token)).to be_nil + end + + it 'does not cache a credential rejection' do + conn = stub_faraday_response(status: 401, body: { 'error' => 'invalid_client' }) + allow(Rails.logger).to receive(:error) + described_class.introspect(token) + described_class.introspect(token) + expect(conn).to have_received(:post).twice + end + + it 'returns nil on a network/timeout error (fails closed)' do + stub_faraday_raise(Faraday::TimeoutError.new('execution expired')) + expect(described_class.introspect(token)).to be_nil + end + + it 'returns nil on malformed JSON (fails closed)' do + stub_faraday_raise(Faraday::ParsingError.new(StandardError.new('unexpected token'))) + expect(described_class.introspect(token)).to be_nil + end + + it 'returns nil when an active response has no subject' do + stub_faraday_response(status: 200, body: active_body.except('sub')) + expect(described_class.introspect(token)).to be_nil + end + + it 'accepts a lower-case token_type (the claim is compared case-insensitively)' do + stub_faraday_response(status: 200, body: active_body.merge('token_type' => 'bearer')) + expect(described_class.introspect(token)).not_to be_nil + end + + it 'rejects a DPoP-bound token presented as a plain bearer' do + # sp331's own client-facing API is Bearer-only; a sender-constrained token proves nothing + # about whoever presents it here, so tmc-server must not be the weaker backend. + stub_faraday_response(status: 200, body: active_body.merge('token_type' => 'DPoP')) + expect(Rails.logger).to receive(:warn).with(/is not Bearer/) + expect(described_class.introspect(token)).to be_nil + end + + it 'rejects an active response that carries no token_type (fails closed)' do + stub_faraday_response(status: 200, body: active_body.except('token_type')) + expect(described_class.introspect(token)).to be_nil + end + + it 'does not cache a token rejected for its token_type' do + conn = stub_faraday_response(status: 200, body: active_body.merge('token_type' => 'DPoP')) + described_class.introspect(token) + described_class.introspect(token) + expect(conn).to have_received(:post).twice + end + + it 'rejects a token issued by an unexpected issuer' do + stub_faraday_response(status: 200, body: active_body.merge('iss' => 'https://evil.example/oauth')) + expect(Rails.logger).to receive(:warn).with(/iss /) + expect(described_class.introspect(token)).to be_nil + end + + it 'rejects an active response that carries no iss (fails closed)' do + stub_faraday_response(status: 200, body: active_body.except('iss')) + expect(described_class.introspect(token)).to be_nil + end + + it 'derives the expected issuer from the configured introspection URL' do + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url') + .and_return('http://project-331.local/api/v0/main-frontend/oauth/introspect') + stub_faraday_response( + status: 200, + body: active_body.merge('iss' => 'http://project-331.local/api/v0/main-frontend/oauth') + ) + expect(described_class.introspect(token)).not_to be_nil + end + + it 'refuses to introspect when the configured URL has no /introspect suffix' do + allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url') + .and_return('https://courses.mooc.fi/api/v0/main-frontend/oauth') + stub_faraday_response(status: 200, body: active_body) + expect(Rails.logger).to receive(:error).with(/does not end in \/introspect/) + expect(described_class.introspect(token)).to be_nil + end + + it 'does not cache a token rejected for its issuer' do + conn = stub_faraday_response(status: 200, body: active_body.merge('iss' => 'https://evil.example/oauth')) + described_class.introspect(token) + described_class.introspect(token) + expect(conn).to have_received(:post).twice + end + + it 'accepts a token whose client is allowed to use bearer tokens' do + stub_faraday_response(status: 200, body: active_body.merge('client_bearer_allowed' => true)) + expect(described_class.introspect(token)).not_to be_nil + end + + it 'rejects a token whose client is not allowed to use bearer tokens' do + stub_faraday_response(status: 200, body: active_body.merge('client_bearer_allowed' => false)) + expect(Rails.logger).to receive(:warn).with(/client_bearer_allowed/) + expect(described_class.introspect(token)).to be_nil + end + + it 'rejects an active response that carries no client_bearer_allowed (fails closed)' do + stub_faraday_response(status: 200, body: active_body.except('client_bearer_allowed')) + expect(described_class.introspect(token)).to be_nil + end + + it 'does not cache a token rejected for client_bearer_allowed' do + conn = stub_faraday_response(status: 200, body: active_body.merge('client_bearer_allowed' => false)) + described_class.introspect(token) + described_class.introspect(token) + expect(conn).to have_received(:post).twice + end + end + + describe 'caching' do + it 'caches positive results and does not re-query the provider' do + conn = stub_faraday_response(status: 200, body: active_body) + + first = described_class.introspect(token) + second = described_class.introspect(token) + + expect(first.sub).to eq(sub) + expect(second.sub).to eq(sub) + expect(conn).to have_received(:post).once + end + + it 'never caches failures' do + conn = stub_faraday_response(status: 500, body: active_body) + described_class.introspect(token) + described_class.introspect(token) + expect(conn).to have_received(:post).twice + end + + it 'caps the TTL at MAX_CACHE_TTL for a long-lived token' do + allow(cache).to receive(:write).and_call_original + stub_faraday_response(status: 200, body: active_body.merge('exp' => (Time.now + 3600).to_i)) + + described_class.introspect(token) + + expect(cache).to have_received(:write).with( + anything, anything, hash_including(expires_in: described_class::MAX_CACHE_TTL) + ) + end + + it 'uses the remaining lifetime when it is shorter than MAX_CACHE_TTL' do + allow(cache).to receive(:write).and_call_original + stub_faraday_response(status: 200, body: active_body.merge('exp' => (Time.now + 60).to_i)) + + described_class.introspect(token) + + expect(cache).to have_received(:write) do |_key, _value, opts| + expect(opts[:expires_in]).to be <= 60 + expect(opts[:expires_in]).to be > 0 + end + end + + it 'does not cache an already-expired token' do + allow(cache).to receive(:write).and_call_original + stub_faraday_response(status: 200, body: active_body.merge('exp' => (Time.now - 1).to_i)) + + described_class.introspect(token) + + expect(cache).not_to have_received(:write) + end + end +end From ca271d3e63b800e2f7f724123ad7a10945b791c9 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 29 Jul 2026 15:42:10 +0300 Subject: [PATCH 2/6] Read secrets through AppSecrets instead of Rails.application.secrets --- app/models/user.rb | 8 ++-- .../courses_mooc_fi_token_introspector.rb | 5 ++- .../initializers/doorkeeper_openid_connect.rb | 6 ++- lib/app_secrets.rb | 43 +++++++++++++++++++ ...courses_mooc_fi_token_introspector_spec.rb | 12 +++--- 5 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 lib/app_secrets.rb diff --git a/app/models/user.rb b/app/models/user.rb index 85e18e3d3..f3ceb9f06 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 @@ -206,7 +208,7 @@ def authenticate_via_courses_mooc_fi(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, @@ -256,7 +258,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, @@ -314,7 +316,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, diff --git a/app/services/courses_mooc_fi_token_introspector.rb b/app/services/courses_mooc_fi_token_introspector.rb index 9bc2037c9..d4a70ff9a 100644 --- a/app/services/courses_mooc_fi_token_introspector.rb +++ b/app/services/courses_mooc_fi_token_introspector.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'app_secrets' require 'digest' # Validates courses.mooc.fi (secret-project-331) OAuth2 access tokens against the @@ -62,8 +63,8 @@ def introspect(token) private def request_introspection(token) url = SiteSetting.value('courses_mooc_fi_introspection_url') - client_id = Rails.application.secrets.courses_mooc_fi_introspection_client_id - client_secret = Rails.application.secrets.courses_mooc_fi_introspection_secret + client_id = AppSecrets.courses_mooc_fi_introspection_client_id + client_secret = AppSecrets.courses_mooc_fi_introspection_secret # A flag-on-but-unconfigured deploy (blank URL or missing client credentials) would otherwise # silently fail closed to Guest with no clue why. Emit one distinct warn so the misconfig is 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/lib/app_secrets.rb b/lib/app_secrets.rb new file mode 100644 index 000000000..e17434896 --- /dev/null +++ b/lib/app_secrets.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Application secrets, read from config/secrets.yml. +# +# Replaces `Rails.application.secrets`, which Rails 7.1 deprecates (it warns once per +# process, from the first reader during boot) and Rails 7.2 removes outright. +# +# The deprecation points at `Rails.application.credentials` instead, but that is not a +# drop-in here: credentials wants an encrypted config/credentials.yml.enc plus a master +# key to decrypt it, and this app has neither. config/secrets.yml deliberately carries +# working plaintext defaults for development and test — so a fresh checkout boots and +# the suite runs with no key material to fetch — and reads everything else from ENV in +# production. Moving to credentials would mean distributing a master key to every +# deploy and every developer, and re-doing how production gets its values. That is a +# deployment decision, not a deprecation fix. +# +# So keep the file and read it the way Rails still supports: `config_for` parses the +# same per-environment, ERB-enabled YAML and returns an ActiveSupport::OrderedOptions, +# which is the dot-accessible, nil-for-missing-key object `Rails.application.secrets` +# already handed back. Call sites are unchanged apart from the receiver. +# +# Lives in lib/ rather than app/ because config/initializers/doorkeeper_openid_connect.rb +# needs it at boot, before app/ autoloading is safe to lean on. lib/ is on $LOAD_PATH, +# so `require 'app_secrets'` works from anywhere — the same way lib/submission_processor.rb +# and friends are used. +module AppSecrets + class << self + # Memoized: secrets.yml is only read at boot anyway ("be sure to restart your + # server when you modify this file"), and re-running ERB per lookup would mean + # re-reading the file on every introspection call. + def config + @config ||= Rails.application.config_for(:secrets) + end + + # Reset the memo. For specs that need to observe a different secrets.yml; not + # something application code should call. + def reload! + @config = nil + end + + delegate_missing_to :config + end +end diff --git a/spec/services/courses_mooc_fi_token_introspector_spec.rb b/spec/services/courses_mooc_fi_token_introspector_spec.rb index 279ca87ff..16b131f6c 100644 --- a/spec/services/courses_mooc_fi_token_introspector_spec.rb +++ b/spec/services/courses_mooc_fi_token_introspector_spec.rb @@ -76,16 +76,16 @@ def stub_faraday_raise(error) end it 'sends the token, both client credentials, and the Accept header in the request' do - allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_client_id).and_return('client-abc') - allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_secret).and_return('secret-xyz') + 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_faraday_response(status: 200, body: active_body) described_class.introspect(token) expect(sent_body).to include( token: token, - client_id: Rails.application.secrets.courses_mooc_fi_introspection_client_id, - client_secret: Rails.application.secrets.courses_mooc_fi_introspection_secret + client_id: AppSecrets.courses_mooc_fi_introspection_client_id, + client_secret: AppSecrets.courses_mooc_fi_introspection_secret ) expect(sent_body[:client_id]).to eq('client-abc') expect(sent_body[:client_secret]).to eq('secret-xyz') @@ -93,8 +93,8 @@ def stub_faraday_raise(error) end it 'returns nil and warns when the introspection is not configured (missing secrets)' do - allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_client_id).and_return('') - allow(Rails.application.secrets).to receive(:courses_mooc_fi_introspection_secret).and_return('') + allow(AppSecrets).to receive(:courses_mooc_fi_introspection_client_id).and_return('') + allow(AppSecrets).to receive(:courses_mooc_fi_introspection_secret).and_return('') expect(Rails.logger).to receive(:warn).with(/not configured/) expect(Faraday).not_to receive(:new) expect(described_class.introspect(token)).to be_nil From 34fa3eb316a3f112c839af0ef3979be87b2fa991 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 29 Jul 2026 13:11:24 +0300 Subject: [PATCH 3/6] Fill in API v8 controller specs and make the API docs valid Swagger --- Installation.md | 45 +++++++++++ app/controllers/api/v8/apidocs_controller.rb | 6 +- .../v8/core/exercises/details_controller.rb | 12 ++- .../exercises/users/submissions_controller.rb | 4 +- app/controllers/api/v8/users_controller.rb | 7 +- .../api/v8/apidocs_controller_spec.rb | 15 +++- .../api/v8/base_controller_spec.rb | 2 +- .../exercises/submissions_controller_spec.rb | 81 ++++++++++++++++--- 8 files changed, 142 insertions(+), 30 deletions(-) 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/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 097b7a4ff..290c10a64 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' response 403, '$ref': '#/responses/error' response 404, '$ref': '#/responses/error' response 200 do @@ -76,18 +76,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] diff --git a/spec/controllers/api/v8/apidocs_controller_spec.rb b/spec/controllers/api/v8/apidocs_controller_spec.rb index 31bde373b..af4223839 100644 --- a/spec/controllers/api/v8/apidocs_controller_spec.rb +++ b/spec/controllers/api/v8/apidocs_controller_spec.rb @@ -5,12 +5,21 @@ 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 JSON schema only requires a path key to start with a slash, so it + # cannot catch a query string smuggled into the key. Query parameters belong in + # `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..b065d666b 100644 --- a/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb +++ b/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb @@ -1,35 +1,96 @@ # frozen_string_literal: true require 'spec_helper' +require 'tmpdir' describe Api::V8::Core::Exercises::SubmissionsController, type: :controller do + let(:organization) { FactoryBot.create(:accepted_organization) } + let(:course) { FactoryBot.create(:course, organization: organization) } + # returnable_exercise sets returnable_forced, so the exercise accepts submissions without a + # refreshed course repository behind it -- #create never looks at the exercise's 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 + + # The controller only inspects the uploaded bytes far enough to see the ZIP magic number + # ("PK"), so a minimal empty-archive header is enough for the accepted path and any other + # content for the declined one. + def upload(contents, filename) + path = File.join(Dir.mktmpdir, filename) + File.binwrite(path, contents) + Rack::Test::UploadedFile.new(path, 'application/octet-stream') + end + + let(:zip_file) { upload("PK\x05\x06#{"\x00" * 18}", 'submission.zip') } + let(:text_file) { upload('this is not an archive', 'submission.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 { post :create, params: { exercise_id: exercise.id, submission: { file: zip_file } }, format: :json } + .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 { post :create, params: { exercise_id: exercise.id, submission: { file: zip_file } }, format: :json } + .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 + # A non-ZIP upload is reported in the response body rather than by status code: the client + # gets 200 with an "error" key and nothing is stored. Asserting the status alone would pass + # even if the magic-number check were removed, so assert the body and the count too. it 'should decline submissions when the file is not ZIP' do - pending('test that submission file is declined when not zip') - raise + expect { post :create, params: { exercise_id: exercise.id, submission: { file: text_file } }, format: :json } + .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 { post :create, params: { exercise_id: exercise.id }, format: :json } + .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 + # No Doorkeeper token and no session, so the controller resolves Guest and + # unauthorize_guest! rejects the request before the exercise is even loaded. + let(:token) { nil } + it 'should not allow sending submission' do - pending('test that submission is declined when no file is given') - raise + expect { post :create, params: { exercise_id: exercise.id, submission: { file: zip_file } }, format: :json } + .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 From 2dc43e977d9263e095f227c6025fa96c28a14a2b Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 6 Oct 2026 11:01:18 +0300 Subject: [PATCH 4/6] Move token auth into a service, cache rejections, answer 503 on outage --- app/controllers/api/v8/base_controller.rb | 102 +---- app/models/user.rb | 7 +- .../courses_mooc_fi_authentication.rb | 40 ++ .../courses_mooc_fi_token_introspector.rb | 244 ++++-------- config/secrets.yml | 7 +- config/site.defaults.yml | 8 +- .../api/v8/courses_mooc_fi_token_auth_spec.rb | 201 +++++----- spec/models/user_spec.rb | 9 + ...ses_mooc_fi_introspection_contract_spec.rb | 123 ++---- ...courses_mooc_fi_token_introspector_spec.rb | 351 +++++------------- spec/support/courses_mooc_fi_introspection.rb | 39 ++ 11 files changed, 392 insertions(+), 739 deletions(-) create mode 100644 app/services/courses_mooc_fi_authentication.rb create mode 100644 spec/support/courses_mooc_fi_introspection.rb diff --git a/app/controllers/api/v8/base_controller.rb b/app/controllers/api/v8/base_controller.rb index ff2818873..1f0b32d72 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 @@ -34,113 +39,20 @@ def present(hash) end end - # The exact UUID format the User model enforces on courses_mooc_fi_user_id (see - # app/models/user.rb). Duplicated here so the introspected subject is shape-checked before - # any lookup or the update_column backfill, which bypasses that load-bearing model validation. - COURSES_MOOC_FI_USER_ID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/ - private def authenticate_user! return @current_user if @current_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 && (bearer = bearer_token).present? - @current_user ||= user_from_courses_mooc_fi_token(bearer) + elsif Rails.configuration.x.accept_courses_mooc_fi_tokens + @current_user = CoursesMoocFiAuthentication.user_for(request) end @current_user ||= user_from_session || Guest.new end attr_reader :current_user - # Additive, feature-flagged auth path (Rails.configuration.x.accept_courses_mooc_fi_tokens, - # default off). Only reached when there is no native Doorkeeper token. Treats the bearer as - # a courses.mooc.fi (secret-project-331) OAuth token, validates it via RFC 7662 - # introspection, and maps it to a local user. Fails closed to nil (caller resolves Guest) - # on any problem; never raises. - def user_from_courses_mooc_fi_token(token) - result = CoursesMoocFiTokenIntrospector.introspect(token) - return nil unless result - - unless result.scope?('exercise-services') - Rails.logger.warn('courses.mooc.fi token rejected: missing exercise-services scope') - return nil - end - - # The subject is about to be used as courses_mooc_fi_user_id, both for the find_by below - # and (on a cache miss) for the update_column backfill, which bypasses the model's - # load-bearing UUID-format validation. Shape-check it once here so a malformed subject can - # neither be looked up nor persisted. Fail closed on mismatch. - unless COURSES_MOOC_FI_USER_ID_FORMAT.match?(result.sub) - Rails.logger.warn('courses.mooc.fi token rejected: subject is not a valid UUID') - return nil - end - - user = User.find_by(courses_mooc_fi_user_id: result.sub) - user ||= backfill_from_upstream_id(result) - return nil unless user - - # Decision 5 (widened 2026-07-23): introspected tokens must never resolve to an elevated - # user. Originally admins-only; now also blocks anyone holding any teachership or - # assistantship, because those grant real CanCan abilities (manage exercises/deadlines, - # read others' submissions). Elevated users keep using native tmc tokens; fail closed to - # Guest here. - reason = elevated_user_reason(user) - if reason - Rails.logger.warn("courses.mooc.fi token resolved to #{reason} user #{user.id}; refusing introspected auth (elevated users must use native tmc tokens)") - return nil - end - - user - rescue => e - Rails.logger.warn("courses.mooc.fi token authentication error: #{e.class}: #{e.message}") - nil - end - - # nil when the user holds no elevated role; otherwise a short reason naming the highest - # concern (administrator > teacher > assistant), used only for the warn log. Uses efficient - # existence checks rather than loading and iterating every organization/course. - def elevated_user_reason(user) - return 'administrator' if user.administrator? - return 'teacher' if Teachership.exists?(user_id: user.id) - return 'assistant' if Assistantship.exists?(user_id: user.id) - nil - end - - # No user is mapped to this token's subject yet, but the introspection response carries the - # TMC integer id (upstream_id). Look the user up by it and backfill the UUID so later - # requests resolve directly. Guarded against the unique-index race and against clobbering a - # user already bound to a different subject. - def backfill_from_upstream_id(result) - upstream_id = result.upstream_id - return nil if upstream_id.blank? - - user = User.find_by(id: upstream_id) - return nil unless user - - if user.courses_mooc_fi_user_id.blank? - begin - user.update_column(:courses_mooc_fi_user_id, result.sub) - rescue ActiveRecord::RecordNotUnique - # Another request backfilled the same subject first. Trust the authoritative mapping. - user = User.find_by(courses_mooc_fi_user_id: result.sub) - end - elsif user.courses_mooc_fi_user_id != result.sub - # upstream_id points at a user already bound to a different subject. Do not override. - Rails.logger.warn("courses.mooc.fi upstream_id #{upstream_id} maps to user #{user.id} already bound to a different courses_mooc_fi_user_id; refusing") - return nil - end - - user - end - - def bearer_token - auth = request.authorization - return nil unless auth - match = auth.match(/\ABearer[ ]+(.+)\z/i) - match && match[1] - end - def errors_json(messages) { errors: [*messages] } end diff --git a/app/models/user.rb b/app/models/user.rb index f3645cdf7..6157bc11b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -48,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 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 index f991ece2a..3556135b9 100644 --- a/app/services/courses_mooc_fi_token_introspector.rb +++ b/app/services/courses_mooc_fi_token_introspector.rb @@ -3,35 +3,24 @@ require 'app_secrets' require 'digest' -# Validates courses.mooc.fi (secret-project-331) OAuth2 access tokens against the -# provider's RFC 7662 token introspection endpoint. -# -# This is the tmc-server side of the auth migration: newer tmc-vscode versions log -# in once at courses.mooc.fi and send that bearer token to both backends. tmc-server -# cannot validate such a token locally (it is opaque and lives in the sp331 -# database), so it asks the provider whether the token is active and who it belongs -# to. -# -# Fails closed: any error at all (missing config, network failure, non-200 status, -# malformed JSON, an inactive token, a response without a subject) yields nil, so -# the caller falls back to Guest. Only positive results are cached, and never longer -# than the token's own remaining lifetime nor MAX_CACHE_TTL. Failures are never -# cached. +# Checks an opaque courses.mooc.fi (secret-project-331) access token via RFC 7662 introspection. class CoursesMoocFiTokenIntrospector - # Never trust a cached positive result longer than this, even for long-lived - # tokens (seconds). - MAX_CACHE_TTL = 300 - CACHE_NAMESPACE = 'courses_mooc_fi_introspection' + # 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 validated, active introspection response. Marshalable so it round-trips - # through Rails.cache. - Result = Struct.new(:sub, :scopes, :upstream_id, :expires_at, keyword_init: true) do - def scope?(name) - scopes.include?(name) - end - 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 a Result for an active token, or nil on any failure / inactive token. + # 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 @@ -39,174 +28,81 @@ def self.introspect(token) def introspect(token) return nil if token.blank? - cached = read_cache(token) - return cached if cached + 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) - return nil unless body.is_a?(Hash) - return nil unless body['active'] == true - return nil unless expected_issuer?(body) - return nil unless bearer_token_type?(body) - return nil unless client_bearer_allowed?(body) - - result = build_result(body) - return nil if result.nil? + 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 - write_cache(token, result) + 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 - rescue => e - # Fail closed on anything unexpected (network, JSON parse, etc.). - Rails.logger.warn("courses.mooc.fi token introspection failed: #{e.class}: #{e.message}") - nil 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) - url = SiteSetting.value('courses_mooc_fi_introspection_url') client_id = AppSecrets.courses_mooc_fi_introspection_client_id client_secret = AppSecrets.courses_mooc_fi_introspection_secret - - # A flag-on-but-unconfigured deploy (blank URL or missing client credentials) would otherwise - # silently fail closed to Guest with no clue why. Emit one distinct warn so the misconfig is - # diagnosable; still fail closed. Distinct from the network-failure warn in #introspect. - if url.blank? || client_id.blank? || client_secret.blank? - Rails.logger.warn('courses.mooc.fi token introspection is not configured (missing URL or client credentials); refusing to introspect') - return nil + 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 - # Tight timeouts so a hung provider can never stall an authenticated request. RFC 7662 - # client_secret_post: client credentials go in the form body. - conn = Faraday.new(request: { open_timeout: 2, timeout: 5 }) do |f| + connection = Faraday.new(request: { open_timeout: 2, timeout: 5 }) do |f| f.request :url_encoded f.response :json end - - response = conn.post(url) do |req| - req.headers['Accept'] = 'application/json' - req.body = { - token: token, - client_id: client_id, - client_secret: client_secret - } - end - - return nil if rejected_our_credentials?(response) - return nil unless response.status == 200 - response.body - end - - # A wrong-but-non-blank COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID or secret is - # indistinguishable from a bad user token at the call site — both just fail closed to Guest — - # so without this it presents as "every user is logged out" with nothing naming the cause. - # The provider answers 401 invalid_client for rejected client credentials (RFC 7662 §2.3), - # separately from the 200 `active: false` it uses for an inactive token, so the two can be - # told apart. Log at error: this is our own misconfiguration, not a user's problem, and it - # affects every request rather than one. - def rejected_our_credentials?(response) - return false unless response.status == 401 - - error = response.body.is_a?(Hash) ? response.body['error'] : nil - Rails.logger.error("courses.mooc.fi rejected tmc-server's own introspection client credentials (HTTP 401, error #{error.inspect}); check COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID and COURSES_MOOC_FI_INTROSPECTION_SECRET. No user can authenticate via courses.mooc.fi until this is fixed.") - true - end - - # The provider stamps every active response with `iss`, its OAuth issuer identifier - # ("/api/v0/main-frontend/oauth"). Verify it so a response cannot be honoured as - # though it came from the configured provider when it did not — a misdirected - # courses_mooc_fi_introspection_url, or a proxy answering in its place. - # - # The expected value is derived from that same setting rather than configured separately: - # sp331 serves this endpoint at "/introspect", so the issuer is the configured URL - # minus that suffix. A second setting would only add a way for the two to disagree, and an - # expected-issuer knob nobody sets would verify nothing. - # - # `aud` is deliberately NOT verified: sp331 creates every access token with a null audience, - # so the member is never emitted (see spec/fixtures/courses_mooc_fi_introspection/). There is - # nothing to compare against, and requiring it would reject every token. Audience would only - # start to matter if tokens were minted for a specific resource server; today the - # exercise-services scope check at the call site is what limits what a token can be used for. - def expected_issuer?(body) - url = SiteSetting.value('courses_mooc_fi_introspection_url').to_s - expected = url[%r{\A(.*)/introspect/?\z}, 1] - - if expected.blank? - Rails.logger.error("courses.mooc.fi introspection URL #{url.inspect} does not end in /introspect, so the expected issuer cannot be derived; refusing to introspect") - return false - end - - return true if body['iss'] == expected - - Rails.logger.warn("courses.mooc.fi token rejected: iss #{body['iss'].inspect} is not #{expected.inspect}") - false - end - - # The provider mints both plain Bearer and sender-constrained (DPoP) access tokens and reports - # which via the introspection response's `token_type` ("Bearer" / "DPoP"). A DPoP-bound token - # proves nothing about whoever presents it as a plain bearer, and sp331's own client-facing API - # refuses one for exactly that reason (see secret-project-331 - # server/src/domain/exercise_services/token.rs, which requires token_type == Bearer). tmc-server - # only ever reads tokens out of an `Authorization: Bearer` header, so mirror that rule instead of - # being the weaker of the two backends. - # - # A missing/unrecognised token_type is treated as not-Bearer: this class fails closed, and the - # provider always sends the claim for an active token. - def bearer_token_type?(body) - token_type = body['token_type'] - return true if token_type.to_s.casecmp('bearer').zero? - - Rails.logger.warn("courses.mooc.fi token rejected: token_type #{token_type.inspect} is not Bearer") - false - end - - # The provider's introspection response carries a non-standard `client_bearer_allowed` member: - # whether the client the token was issued to may use plain Bearer tokens (sp331's own - # client-facing extractor refuses the token otherwise, requiring client.allows_bearer()). It is - # sent only to confidential callers (tmc-server always qualifies) and is OMITTED, not `false`, - # when withheld — so absence means "no assertion", not "allowed", and must fail closed too. - # Hence `== true` rather than Ruby truthiness. - def client_bearer_allowed?(body) - return true if body['client_bearer_allowed'] == true - - Rails.logger.warn("courses.mooc.fi token rejected: client_bearer_allowed #{body['client_bearer_allowed'].inspect} is not true") - false - end - - def build_result(body) - sub = body['sub'] - return nil if sub.blank? - - scopes = body['scope'].to_s.split(' ') - exp = body['exp'] - expires_at = exp.is_a?(Numeric) ? Time.at(exp) : nil - - Result.new( - sub: sub, - scopes: scopes, - upstream_id: body['upstream_id'], - expires_at: expires_at + response = connection.post( + "#{issuer}/introspect", + { token: token, client_id: client_id, client_secret: client_secret }, + 'Accept' => 'application/json' ) - end - - def cache_key(token) - "#{CACHE_NAMESPACE}:#{Digest::SHA256.hexdigest(token)}" - end - def read_cache(token) - Rails.cache.read(cache_key(token)) + 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 - def write_cache(token, result) - ttl = cache_ttl(result) - return if ttl.nil? || ttl <= 0 - Rails.cache.write(cache_key(token), result, expires_in: ttl) + # `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 - # TTL = min(exp - now, MAX_CACHE_TTL). A token that carries no exp is still - # cached, but only up to MAX_CACHE_TTL. An already-expired token is not cached. - def cache_ttl(result) - return MAX_CACHE_TTL if result.expires_at.nil? - remaining = (result.expires_at - Time.now).floor - return nil if remaining <= 0 - [remaining, MAX_CACHE_TTL].min + 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/secrets.yml b/config/secrets.yml index 5418a06a4..eca7faccf 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -42,6 +42,7 @@ 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" %> @@ -77,12 +78,6 @@ test: wvTexLwtU6i38xTpGMeTsQVx -----END PRIVATE KEY----- tmc_server_secret_for_communicating_to_secret_project: <%= ENV["TMC_SERVER_SECRET_FOR_COMMUNICATING_TO_SECRET_PROJECT"] || "Zm9yIGxvY2FsIGRldmVsb3BtZW50IG9ubHksIGludGVudGlvbmFsbHkgcHVibGlj" %> - # Deliberately the same client as the development default. The test suite never contacts a real - # introspection endpoint (courses_mooc_fi_introspection_url is blank in config/site.defaults.yml, - # and the specs stub Faraday / the introspector outright), so this value is only ever echoed back - # in assertions. secret-project-331 seeds exactly one introspection client for dev/CI, - # `tmc-server-introspection-dev` (see its seed_oauth_clients.rs); a distinct `-test` id would be a - # client that exists nowhere. 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" %> diff --git a/config/site.defaults.yml b/config/site.defaults.yml index 73e94744b..333dbdadb 100644 --- a/config/site.defaults.yml +++ b/config/site.defaults.yml @@ -139,10 +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: - -# RFC 7662 introspection endpoint for validating courses.mooc.fi OAuth tokens. Only used when the -# ACCEPT_COURSES_MOOC_FI_TOKENS flag is on. Set per deployment, e.g. -# https://courses.mooc.fi/api/v0/main-frontend/oauth/introspect -courses_mooc_fi_introspection_url: 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 index f30708038..7b17cf17c 100644 --- a/spec/controllers/api/v8/courses_mooc_fi_token_auth_spec.rb +++ b/spec/controllers/api/v8/courses_mooc_fi_token_auth_spec.rb @@ -2,15 +2,12 @@ require 'spec_helper' -# Exercises the additive, feature-flagged courses.mooc.fi (secret-project-331) token -# introspection branch in Api::V8::BaseController#authenticate_user!. Mirrors the -# UselessController pattern from base_controller_spec.rb. class MoocTokenUselessController < Api::V8::BaseController end RSpec.describe Api::V8::BaseController, type: :controller do controller MoocTokenUselessController do - skip_authorization_check # not testing cancan here + skip_authorization_check def index render plain: 'Success' end @@ -21,13 +18,14 @@ def index let(:sub) { '11111111-2222-3333-4444-555555555555' } let(:bearer) { 'sp331-access-token' } - def result(scopes: ['exercise-services'], upstream_id: nil, subject_id: sub) - CoursesMoocFiTokenIntrospector::Result.new( - sub: subject_id, scopes: scopes, upstream_id: upstream_id, expires_at: Time.now + 3600 - ) + 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 - # Save/restore the flag so tests never leak global state. around do |example| original = Rails.configuration.x.accept_courses_mooc_fi_tokens example.run @@ -35,180 +33,153 @@ def result(scopes: ['exercise-services'], upstream_id: nil, subject_id: sub) end before do - # No native Doorkeeper token in these tests unless a context overrides it. allow(controller).to receive(:doorkeeper_token).and_return(nil) request.headers['Authorization'] = "Bearer #{bearer}" end - context 'when the flag is off (default behaviour, regression)' do + context 'when the flag is off' do before { Rails.configuration.x.accept_courses_mooc_fi_tokens = false } - it 'never introspects and resolves to Guest' do - expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) - get :index - expect(current_user).to be_guest - end - - it 'does not even read the bearer token when the flag is off, despite a bearer header' do - # The flag is the first operand of the && guard, so short-circuit evaluation must skip - # bearer_token (and therefore introspection) entirely even though an unknown bearer is - # present. Pins the flag-off path's evaluation-order invariant. - expect(controller).not_to receive(:bearer_token) + it 'never introspects a bearer token' do expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) get :index expect(current_user).to be_guest end - - it 'still authenticates 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)) - get :index - expect(current_user.id).to eq(user.id) - end end context 'when the flag is on' do before { Rails.configuration.x.accept_courses_mooc_fi_tokens = true } - it 'still prefers a native Doorkeeper token over introspection' do + 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.id).to eq(user.id) + expect(current_user).to eq(user) end - it 'resolves the mapped user for a valid introspected token' do + it 'resolves the user bound to the token subject' do user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).with(bearer).and_return(result) + introspection_returns(result) get :index - expect(current_user.id).to eq(user.id) + expect(current_user).to eq(user) end - it 'resolves to Guest when the exercise-services scope is missing' do - FactoryBot.create(:user, courses_mooc_fi_user_id: sub) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result(scopes: ['other-scope'])) + 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 be_guest + expect(current_user).to eq(user) end - it 'resolves to Guest when introspection fails' do - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(nil) + 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 'resolves to Guest and warns when the subject is not a valid UUID' do + it 'answers 503 when courses.mooc.fi cannot check the token' do allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) - .and_return(result(subject_id: 'not-a-uuid')) - expect(Rails.logger).to receive(:warn).with(/not a valid UUID/) + .and_raise(CoursesMoocFiTokenIntrospector::Unavailable, 'introspection answered HTTP 502') + expect(Rails.logger).to receive(:error).with(/introspection answered HTTP 502/) get :index - expect(current_user).to be_guest + expect(response).to have_http_status(:service_unavailable) + expect(response.body).to include('could not verify your login') end - it 'resolves to Guest and warns when the mapped user is an administrator' do - FactoryBot.create(:admin, courses_mooc_fi_user_id: sub) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result) - expect(Rails.logger).to receive(:warn).with(/administrator/) - get :index - expect(current_user).to be_guest + 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 - it 'resolves to Guest and warns when the mapped user holds a teachership' do - user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) - Teachership.create!(user: user, organization: FactoryBot.create(:organization)) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result) - expect(Rails.logger).to receive(:warn).with(/teacher/) - get :index - expect(current_user).to be_guest + { + '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 - it 'resolves to Guest and warns when the mapped user holds an assistantship' do - user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) - Assistantship.create!(user: user, course: FactoryBot.create(:course)) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return(result) - expect(Rails.logger).to receive(:warn).with(/assistant/) - get :index - expect(current_user).to be_guest + 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 'upstream_id fallback + backfill' do - it 'resolves via upstream_id and backfills courses_mooc_fi_user_id' do + 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) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) - .and_return(result(upstream_id: user.id)) + introspection_returns(result(upstream_id: user.id)) get :index - expect(current_user.id).to eq(user.id) + expect(current_user).to eq(user) expect(user.reload.courses_mooc_fi_user_id).to eq(sub) end - it 'does not authenticate an administrator via the upstream_id fallback' do - admin = FactoryBot.create(:admin, courses_mooc_fi_user_id: nil) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) - .and_return(result(upstream_id: admin.id)) + 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 be_guest + expect(current_user).to eq(user) end - it 'resolves to Guest when neither the subject nor upstream_id match a user' do - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) - .and_return(result(upstream_id: 999_999)) + 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 when upstream_id maps to a user already bound to a different subject' do + 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) - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) - .and_return(result(upstream_id: user.id)) - expect(Rails.logger).to receive(:warn).with(/already bound to a different/) + 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 'trusts the winner of the backfill race when the unique index rejects the write' do - # Two concurrent requests for the same subject: this one loses the unique-index race. - # The rescue must re-read the authoritative mapping rather than fail to Guest. + 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) - # Simulate the racing request committing first, then our write blowing up. update_all - # rather than update_column, which is the stubbed method. 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 - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect) - .and_return(result(upstream_id: loser.id)) - - get :index - - expect(current_user.id).to eq(winner.id) - end - end - - context 'bearer header parsing' do - it 'does not introspect when there is no Authorization header' do - request.headers['Authorization'] = nil - expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) - get :index - expect(current_user).to be_guest - end - - it 'does not introspect a non-Bearer authorization scheme' do - request.headers['Authorization'] = 'Basic dXNlcjpwYXNz' - expect(CoursesMoocFiTokenIntrospector).not_to receive(:introspect) - get :index - expect(current_user).to be_guest - end - - it 'accepts a lower-case bearer scheme and passes the raw token through' do - user = FactoryBot.create(:user, courses_mooc_fi_user_id: sub) - request.headers['Authorization'] = "bearer #{bearer}" - allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).with(bearer).and_return(result) + introspection_returns(result(upstream_id: loser.id)) get :index - expect(current_user.id).to eq(user.id) + expect(current_user).to eq(winner) end end end 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 index 317c90b58..e6067a890 100644 --- a/spec/services/courses_mooc_fi_introspection_contract_spec.rb +++ b/spec/services/courses_mooc_fi_introspection_contract_spec.rb @@ -2,23 +2,16 @@ require 'spec_helper' -# Members whose absence must reject the token: without them nothing has been asserted about -# who the token belongs to or whether it may be presented as a bearer credential. -REQUIRED_INTROSPECTION_MEMBERS = %w[active sub iss token_type client_bearer_allowed].freeze +# 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' -# Members the introspector reads but tolerates the absence of, each for a documented reason. -# They still belong in the contract: a rename breaks the read just as badly, it just degrades -# quietly instead of rejecting. -OPTIONAL_INTROSPECTION_MEMBERS = %w[scope exp upstream_id].freeze + # 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 -# Cross-repo wire contract for the courses.mooc.fi (secret-project-331) introspection response. -# -# Every other spec touching CoursesMoocFiTokenIntrospector builds its response body inline, so -# the member names it reads are only ever compared against themselves. This one drives the -# introspector with a committed fixture mirrored from sp331's own output -# (spec/fixtures/courses_mooc_fi_introspection/, see the README there for provenance) and pins -# the members consumed, so a rename on either side fails here instead of in production. -RSpec.describe 'courses.mooc.fi introspection response contract' do def fixture(name) JSON.parse(File.read(Rails.root.join('spec/fixtures/courses_mooc_fi_introspection', name))) end @@ -26,101 +19,51 @@ def fixture(name) let(:active_response) { fixture('active_response.json') } let(:inactive_response) { fixture('inactive_response.json') } let(:token) { 'sp331-access-token' } - # The introspector derives the issuer it requires from this URL, so the fixture's `iss` has to - # be the one this endpoint implies. - let(:introspection_url) { 'https://courses.mooc.fi/api/v0/main-frontend/oauth/introspect' } - before do - allow(SiteSetting).to receive(:value).and_call_original - allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url').and_return(introspection_url) - allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) - end - - def stub_provider(status:, body:) - response = instance_double(Faraday::Response, status: status, body: body) - conn = instance_double(Faraday::Connection) - allow(conn).to receive(:post).and_return(response) - allow(Faraday).to receive(:new).and_return(conn) - conn - end - - it 'names every member the introspector consumes' do - expect(active_response.keys).to include( - *REQUIRED_INTROSPECTION_MEMBERS, *OPTIONAL_INTROSPECTION_MEMBERS + 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 - it 'types those members as the introspector assumes' do - expect(active_response['active']).to be(true) - expect(active_response['sub']).to be_a(String) - expect(active_response['scope']).to be_a(String) # space-separated, not an array - expect(active_response['exp']).to be_a(Numeric) # Unix seconds, not an ISO 8601 string - expect(active_response['iss']).to be_a(String) - expect(active_response['token_type']).to eq('Bearer') - expect(active_response['upstream_id']).to be_a(Integer) - expect(active_response['client_bearer_allowed']).to be(true) - end - - # sp331 mints every access token with `audience: None`, so verifying `aud` is impossible. If - # this starts failing, the provider gained audience support and the introspector's recorded - # reasoning about not verifying it needs revisiting. + # 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 end to end' do + it 'accepts the real active response' do stub_provider(status: 200, body: active_response) - - result = CoursesMoocFiTokenIntrospector.introspect(token) - - expect(result).not_to be_nil - expect(result.sub).to eq(active_response['sub']) - expect(result).to be_scope('exercise-services') - expect(result.upstream_id).to eq(active_response['upstream_id']) - expect(result.expires_at).to eq(Time.at(active_response['exp'])) + 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' do + 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 - # The negative shape carries no metadata, so nothing downstream can read a subject out of a - # rejected token. - it 'keeps the inactive response free of metadata' do - expect(inactive_response.keys).to eq(['active']) - expect(inactive_response['active']).to be(false) - end - - REQUIRED_INTROSPECTION_MEMBERS.each do |member| - it "fails closed when the provider stops sending #{member}" do + 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 - describe 'members that are optional by design' do - # No scope member means no scopes, which closes the caller's exercise-services gate anyway — - # so this degrades to unauthorized rather than to unauthenticated. - it 'yields no scopes when scope is absent' do - stub_provider(status: 200, body: active_response.except('scope')) - result = CoursesMoocFiTokenIntrospector.introspect(token) - expect(result.scopes).to be_empty - expect(result).not_to be_scope('exercise-services') - end - - # A token without exp is still usable; the cache just falls back to MAX_CACHE_TTL. - it 'yields no expiry when exp is absent' do - stub_provider(status: 200, body: active_response.except('exp')) - expect(CoursesMoocFiTokenIntrospector.introspect(token).expires_at).to be_nil - end - - # A legitimately absent value: the token owner has no legacy TMC account to link. - it 'yields no upstream_id when upstream_id is absent' do - stub_provider(status: 200, body: active_response.except('upstream_id')) - result = CoursesMoocFiTokenIntrospector.introspect(token) - expect(result).not_to be_nil - expect(result.upstream_id).to be_nil + 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 index 16b131f6c..eae4b8b20 100644 --- a/spec/services/courses_mooc_fi_token_introspector_spec.rb +++ b/spec/services/courses_mooc_fi_token_introspector_spec.rb @@ -3,12 +3,10 @@ require 'spec_helper' RSpec.describe CoursesMoocFiTokenIntrospector do + include_context 'courses.mooc.fi introspection provider' + let(:token) { 'sp331-access-token' } - let(:introspection_url) { 'https://courses.mooc.fi/api/v0/main-frontend/oauth/introspect' } let(:sub) { '11111111-2222-3333-4444-555555555555' } - let(:cache) { ActiveSupport::Cache::MemoryStore.new } - - # A fresh, active introspection response. exp far in the future so caching is exercised. let(:active_body) do { 'active' => true, @@ -22,296 +20,145 @@ } end - before do - allow(SiteSetting).to receive(:value).and_call_original - allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url').and_return(introspection_url) - allow(Rails).to receive(:cache).and_return(cache) - end - - # Captures what the introspector's post-block builds, so a regression in the request-building - # block (form body / Accept header) fails a spec instead of passing green. - let(:sent_headers) { {} } - let(:sent_body) { {} } - - # Stand-in for the Faraday::Request yielded to the post block. A plain double (not an - # instance_double) keeps this robust across Faraday versions; it only needs #headers (a mutable - # hash) and #body=. #headers returns the same captured hash so header writes are observable. - let(:request_double) do - req = double('Faraday::Request') - allow(req).to receive(:headers).and_return(sent_headers) - allow(req).to receive(:body=) { |value| sent_body.replace(value) } - req - end - - # Stubs Faraday so no real HTTP happens, but still RUNS the request-building block against - # request_double so its body/header setup is exercised. Returns the connection double. - def stub_faraday_response(status:, body:) - response = instance_double(Faraday::Response, status: status, body: body) - conn = instance_double(Faraday::Connection) - allow(conn).to receive(:post) do |_url, &blk| - blk&.call(request_double) - response - end - allow(Faraday).to receive(:new).and_return(conn) - conn - end - - def stub_faraday_raise(error) - conn = instance_double(Faraday::Connection) - allow(conn).to receive(:post).and_raise(error) - allow(Faraday).to receive(:new).and_return(conn) - conn + def introspect + described_class.introspect(token) end describe '.introspect' do - it 'returns a result for an active token' do - stub_faraday_response(status: 200, body: active_body) - result = described_class.introspect(token) - - expect(result).not_to be_nil - expect(result.sub).to eq(sub) - expect(result.scopes).to contain_exactly('exercise-services', 'other-scope') - expect(result).to be_scope('exercise-services') - expect(result.upstream_id).to eq(42) + 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 'sends the token, both client credentials, and the Accept header in the request' do + 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_faraday_response(status: 200, body: active_body) - - described_class.introspect(token) - - expect(sent_body).to include( - token: token, - client_id: AppSecrets.courses_mooc_fi_introspection_client_id, - client_secret: AppSecrets.courses_mooc_fi_introspection_secret - ) - expect(sent_body[:client_id]).to eq('client-abc') - expect(sent_body[:client_secret]).to eq('secret-xyz') - expect(sent_headers['Accept']).to eq('application/json') - end - - it 'returns nil and warns when the introspection is not configured (missing secrets)' do - allow(AppSecrets).to receive(:courses_mooc_fi_introspection_client_id).and_return('') - allow(AppSecrets).to receive(:courses_mooc_fi_introspection_secret).and_return('') - expect(Rails.logger).to receive(:warn).with(/not configured/) - expect(Faraday).not_to receive(:new) - expect(described_class.introspect(token)).to be_nil - end - - it 'returns nil for a blank token without calling the provider' do - conn = stub_faraday_response(status: 200, body: active_body) - expect(described_class.introspect('')).to be_nil - expect(conn).not_to have_received(:post) - end + stub_provider(status: 200, body: active_body) - it 'returns nil when the introspection URL is not configured' do - allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url').and_return(nil) - expect(Faraday).not_to receive(:new) - expect(described_class.introspect(token)).to be_nil - end - - it 'returns nil when the token is inactive (active: false)' do - stub_faraday_response(status: 200, body: { 'active' => false }) - expect(described_class.introspect(token)).to be_nil - end - - it 'returns nil on a non-200 status' do - stub_faraday_response(status: 500, body: active_body) - expect(described_class.introspect(token)).to be_nil - end - - # Our own credentials being rejected is a deployment fault affecting every user, not a - # statement about this token, so it must be distinguishable from a 200 `active: false`. - it 'logs distinctly at error when the provider rejects our client credentials' do - stub_faraday_response(status: 401, body: { 'error' => 'invalid_client' }) - expect(Rails.logger).to receive(:error).with(/rejected tmc-server's own introspection client credentials/) - expect(described_class.introspect(token)).to be_nil - end + introspect - it 'names the misconfigured environment variables in that log' do - stub_faraday_response(status: 401, body: { 'error' => 'invalid_client' }) - expect(Rails.logger).to receive(:error).with( - /COURSES_MOOC_FI_INTROSPECTION_CLIENT_ID.*COURSES_MOOC_FI_INTROSPECTION_SECRET/ + sent = provider_requests.last + expect(Rack::Utils.parse_query(sent.request_body)).to eq( + 'token' => token, 'client_id' => 'client-abc', 'client_secret' => 'secret-xyz' ) - described_class.introspect(token) - end - - it 'still reports a credential rejection with an unparseable body' do - stub_faraday_response(status: 401, body: 'Unauthorized') - expect(Rails.logger).to receive(:error).with(/introspection client credentials/) - expect(described_class.introspect(token)).to be_nil - end - - it 'does not log a credential rejection for an inactive token' do - stub_faraday_response(status: 200, body: { 'active' => false }) - expect(Rails.logger).not_to receive(:error) - expect(described_class.introspect(token)).to be_nil - end - - it 'does not log a credential rejection for an unrelated server error' do - stub_faraday_response(status: 500, body: active_body) - expect(Rails.logger).not_to receive(:error) - expect(described_class.introspect(token)).to be_nil - end - - it 'does not cache a credential rejection' do - conn = stub_faraday_response(status: 401, body: { 'error' => 'invalid_client' }) - allow(Rails.logger).to receive(:error) - described_class.introspect(token) - described_class.introspect(token) - expect(conn).to have_received(:post).twice - end - - it 'returns nil on a network/timeout error (fails closed)' do - stub_faraday_raise(Faraday::TimeoutError.new('execution expired')) - expect(described_class.introspect(token)).to be_nil - end - - it 'returns nil on malformed JSON (fails closed)' do - stub_faraday_raise(Faraday::ParsingError.new(StandardError.new('unexpected token'))) - expect(described_class.introspect(token)).to be_nil - end - - it 'returns nil when an active response has no subject' do - stub_faraday_response(status: 200, body: active_body.except('sub')) - expect(described_class.introspect(token)).to be_nil - end - - it 'accepts a lower-case token_type (the claim is compared case-insensitively)' do - stub_faraday_response(status: 200, body: active_body.merge('token_type' => 'bearer')) - expect(described_class.introspect(token)).not_to be_nil - end - - it 'rejects a DPoP-bound token presented as a plain bearer' do - # sp331's own client-facing API is Bearer-only; a sender-constrained token proves nothing - # about whoever presents it here, so tmc-server must not be the weaker backend. - stub_faraday_response(status: 200, body: active_body.merge('token_type' => 'DPoP')) - expect(Rails.logger).to receive(:warn).with(/is not Bearer/) - expect(described_class.introspect(token)).to be_nil + expect(sent.request_headers['Accept']).to eq('application/json') end - it 'rejects an active response that carries no token_type (fails closed)' do - stub_faraday_response(status: 200, body: active_body.except('token_type')) - expect(described_class.introspect(token)).to be_nil - end - - it 'does not cache a token rejected for its token_type' do - conn = stub_faraday_response(status: 200, body: active_body.merge('token_type' => 'DPoP')) - described_class.introspect(token) - described_class.introspect(token) - expect(conn).to have_received(:post).twice + 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 'rejects a token issued by an unexpected issuer' do - stub_faraday_response(status: 200, body: active_body.merge('iss' => 'https://evil.example/oauth')) - expect(Rails.logger).to receive(:warn).with(/iss /) - expect(described_class.introspect(token)).to be_nil + 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 'rejects an active response that carries no iss (fails closed)' do - stub_faraday_response(status: 200, body: active_body.except('iss')) - expect(described_class.introspect(token)).to be_nil + 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 'derives the expected issuer from the configured introspection URL' do - allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url') - .and_return('http://project-331.local/api/v0/main-frontend/oauth/introspect') - stub_faraday_response( - status: 200, - body: active_body.merge('iss' => 'http://project-331.local/api/v0/main-frontend/oauth') - ) - expect(described_class.introspect(token)).not_to be_nil - end - - it 'refuses to introspect when the configured URL has no /introspect suffix' do - allow(SiteSetting).to receive(:value).with('courses_mooc_fi_introspection_url') - .and_return('https://courses.mooc.fi/api/v0/main-frontend/oauth') - stub_faraday_response(status: 200, body: active_body) - expect(Rails.logger).to receive(:error).with(/does not end in \/introspect/) - expect(described_class.introspect(token)).to be_nil + 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 - it 'does not cache a token rejected for its issuer' do - conn = stub_faraday_response(status: 200, body: active_body.merge('iss' => 'https://evil.example/oauth')) - described_class.introspect(token) - described_class.introspect(token) - expect(conn).to have_received(:post).twice + { + '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 - it 'accepts a token whose client is allowed to use bearer tokens' do - stub_faraday_response(status: 200, body: active_body.merge('client_bearer_allowed' => true)) - expect(described_class.introspect(token)).not_to be_nil - 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 - it 'rejects a token whose client is not allowed to use bearer tokens' do - stub_faraday_response(status: 200, body: active_body.merge('client_bearer_allowed' => false)) - expect(Rails.logger).to receive(:warn).with(/client_bearer_allowed/) - expect(described_class.introspect(token)).to be_nil - 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 'rejects an active response that carries no client_bearer_allowed (fails closed)' do - stub_faraday_response(status: 200, body: active_body.except('client_bearer_allowed')) - expect(described_class.introspect(token)).to be_nil - 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 'does not cache a token rejected for client_bearer_allowed' do - conn = stub_faraday_response(status: 200, body: active_body.merge('client_bearer_allowed' => false)) - described_class.introspect(token) - described_class.introspect(token) - expect(conn).to have_received(:post).twice + 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 'caches positive results and does not re-query the provider' do - conn = stub_faraday_response(status: 200, body: active_body) - - first = described_class.introspect(token) - second = described_class.introspect(token) + 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 - expect(first.sub).to eq(sub) - expect(second.sub).to eq(sub) - expect(conn).to have_received(:post).once + 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 failures' do - conn = stub_faraday_response(status: 500, body: active_body) - described_class.introspect(token) - described_class.introspect(token) - expect(conn).to have_received(:post).twice + 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 for a long-lived token' do + it 'caps the TTL at MAX_CACHE_TTL' do allow(cache).to receive(:write).and_call_original - stub_faraday_response(status: 200, body: active_body.merge('exp' => (Time.now + 3600).to_i)) - - described_class.introspect(token) - - expect(cache).to have_received(:write).with( - anything, anything, hash_including(expires_in: described_class::MAX_CACHE_TTL) - ) + 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 remaining lifetime when it is shorter than MAX_CACHE_TTL' do + it "uses the token's remaining lifetime when shorter" do allow(cache).to receive(:write).and_call_original - stub_faraday_response(status: 200, body: active_body.merge('exp' => (Time.now + 60).to_i)) - - described_class.introspect(token) - - expect(cache).to have_received(:write) do |_key, _value, opts| - expect(opts[:expires_in]).to be <= 60 - expect(opts[:expires_in]).to be > 0 - end + 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_faraday_response(status: 200, body: active_body.merge('exp' => (Time.now - 1).to_i)) - - described_class.introspect(token) - + stub_provider(status: 200, body: active_body.merge('exp' => (Time.now - 1).to_i)) + introspect expect(cache).not_to have_received(:write) 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 From 8cda1a78bcbfb529c4c5be812accb54aeecc1b59 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 6 Oct 2026 12:51:59 +0300 Subject: [PATCH 5/6] Refuse account changes made with a courses.mooc.fi token --- app/controllers/api/v8/base_controller.rb | 5 ++ app/controllers/api/v8/users_controller.rb | 1 + app/models/ability.rb | 10 ++- .../users/request_deletion_controller_spec.rb | 27 ++++++++ .../api/v8/users_controller_spec.rb | 61 +++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 spec/controllers/api/v8/users/request_deletion_controller_spec.rb diff --git a/app/controllers/api/v8/base_controller.rb b/app/controllers/api/v8/base_controller.rb index 1f0b32d72..c9b362808 100644 --- a/app/controllers/api/v8/base_controller.rb +++ b/app/controllers/api/v8/base_controller.rb @@ -47,12 +47,17 @@ def authenticate_user! 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/users_controller.rb b/app/controllers/api/v8/users_controller.rb index 76e66a4b9..16d110f7d 100644 --- a/app/controllers/api/v8/users_controller.rb +++ b/app/controllers/api/v8/users_controller.rb @@ -248,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/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 From a96dcc6289e285fdf5e0305437ec437a84435e39 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Tue, 6 Oct 2026 12:52:18 +0300 Subject: [PATCH 6/6] Quote the introspection credentials and tidy AppSecrets and specs --- config/application.rb | 3 -- config/secrets.yml | 4 +- lib/app_secrets.rb | 34 +------------ .../api/v8/apidocs_controller_spec.rb | 5 +- .../exercises/submissions_controller_spec.rb | 45 +++++++----------- spec/fixtures/submission_uploads/empty.zip | Bin 0 -> 22 bytes .../fixtures/submission_uploads/not_a_zip.txt | 1 + 7 files changed, 25 insertions(+), 67 deletions(-) create mode 100644 spec/fixtures/submission_uploads/empty.zip create mode 100644 spec/fixtures/submission_uploads/not_a_zip.txt diff --git a/config/application.rb b/config/application.rb index e9d407a15..1b3f4a4e7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,9 +34,6 @@ class Application < Rails::Application config.relative_url_root = SiteSetting.value('base_path') - # Feature flag (default off): accept courses.mooc.fi (secret-project-331) OAuth tokens on the - # API v8 auth path via RFC 7662 introspection. See Api::V8::BaseController#authenticate_user! - # and CoursesMoocFiTokenIntrospector. Additive: with this off every request path is unchanged. 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 diff --git a/config/secrets.yml b/config/secrets.yml index eca7faccf..ba8960e37 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -87,5 +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"] %> - courses_mooc_fi_introspection_secret: <%= ENV["COURSES_MOOC_FI_INTROSPECTION_SECRET"] %> + 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/lib/app_secrets.rb b/lib/app_secrets.rb index e17434896..6aef1f3b1 100644 --- a/lib/app_secrets.rb +++ b/lib/app_secrets.rb @@ -1,43 +1,13 @@ # frozen_string_literal: true -# Application secrets, read from config/secrets.yml. -# -# Replaces `Rails.application.secrets`, which Rails 7.1 deprecates (it warns once per -# process, from the first reader during boot) and Rails 7.2 removes outright. -# -# The deprecation points at `Rails.application.credentials` instead, but that is not a -# drop-in here: credentials wants an encrypted config/credentials.yml.enc plus a master -# key to decrypt it, and this app has neither. config/secrets.yml deliberately carries -# working plaintext defaults for development and test — so a fresh checkout boots and -# the suite runs with no key material to fetch — and reads everything else from ENV in -# production. Moving to credentials would mean distributing a master key to every -# deploy and every developer, and re-doing how production gets its values. That is a -# deployment decision, not a deprecation fix. -# -# So keep the file and read it the way Rails still supports: `config_for` parses the -# same per-environment, ERB-enabled YAML and returns an ActiveSupport::OrderedOptions, -# which is the dot-accessible, nil-for-missing-key object `Rails.application.secrets` -# already handed back. Call sites are unchanged apart from the receiver. -# -# Lives in lib/ rather than app/ because config/initializers/doorkeeper_openid_connect.rb -# needs it at boot, before app/ autoloading is safe to lean on. lib/ is on $LOAD_PATH, -# so `require 'app_secrets'` works from anywhere — the same way lib/submission_processor.rb -# and friends are used. +# 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 - # Memoized: secrets.yml is only read at boot anyway ("be sure to restart your - # server when you modify this file"), and re-running ERB per lookup would mean - # re-reading the file on every introspection call. def config @config ||= Rails.application.config_for(:secrets) end - # Reset the memo. For specs that need to observe a different secrets.yml; not - # something application code should call. - def reload! - @config = nil - 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 af4223839..a63aee5af 100644 --- a/spec/controllers/api/v8/apidocs_controller_spec.rb +++ b/spec/controllers/api/v8/apidocs_controller_spec.rb @@ -13,9 +13,8 @@ expect(errors).to be_empty, -> { "Generated apidocs are not valid Swagger 2.0:\n#{errors.join("\n")}" } end - # The Swagger 2.0 JSON schema only requires a path key to start with a slash, so it - # cannot catch a query string smuggled into the key. Query parameters belong in - # `parameters`, with `in: query`. + # 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 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 b065d666b..8b150b0f5 100644 --- a/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb +++ b/spec/controllers/api/v8/core/exercises/submissions_controller_spec.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true require 'spec_helper' -require 'tmpdir' describe Api::V8::Core::Exercises::SubmissionsController, type: :controller do let(:organization) { FactoryBot.create(:accepted_organization) } let(:course) { FactoryBot.create(:course, organization: organization) } - # returnable_exercise sets returnable_forced, so the exercise accepts submissions without a - # refreshed course repository behind it -- #create never looks at the exercise's files. + # 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) } @@ -15,17 +14,18 @@ allow(controller).to receive(:doorkeeper_token) { token } end - # The controller only inspects the uploaded bytes far enough to see the ZIP magic number - # ("PK"), so a minimal empty-archive header is enough for the accepted path and any other - # content for the declined one. - def upload(contents, filename) - path = File.join(Dir.mktmpdir, filename) - File.binwrite(path, contents) - Rack::Test::UploadedFile.new(path, 'application/octet-stream') + def upload(name) + Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/submission_uploads', name), 'application/octet-stream') end - let(:zip_file) { upload("PK\x05\x06#{"\x00" * 18}", 'submission.zip') } - let(:text_file) { upload('this is not an archive', 'submission.txt') } + 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 @@ -35,8 +35,7 @@ def upload(contents, filename) exercise.deadline_spec = ['1.1.2100'].to_json exercise.save! - expect { post :create, params: { exercise_id: exercise.id, submission: { file: zip_file } }, format: :json } - .to change(Submission, :count).by(1) + expect { create_submission(zip_file) }.to change(Submission, :count).by(1) expect(response).to have_http_status :ok json = JSON.parse(response.body) @@ -53,27 +52,22 @@ def upload(contents, filename) exercise.deadline_spec = ['1.1.2000'].to_json exercise.save! - expect { post :create, params: { exercise_id: exercise.id, submission: { file: zip_file } }, format: :json } - .not_to change(Submission, :count) + 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 - # A non-ZIP upload is reported in the response body rather than by status code: the client - # gets 200 with an "error" key and nothing is stored. Asserting the status alone would pass - # even if the magic-number check were removed, so assert the body and the count too. + # Refused in the body only; the status stays 200. it 'should decline submissions when the file is not ZIP' do - expect { post :create, params: { exercise_id: exercise.id, submission: { file: text_file } }, format: :json } - .not_to change(Submission, :count) + 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 - expect { post :create, params: { exercise_id: exercise.id }, format: :json } - .not_to change(Submission, :count) + 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') @@ -81,13 +75,10 @@ def upload(contents, filename) end describe 'as an unauthenticated user' do - # No Doorkeeper token and no session, so the controller resolves Guest and - # unauthorize_guest! rejects the request before the exercise is even loaded. let(:token) { nil } it 'should not allow sending submission' do - expect { post :create, params: { exercise_id: exercise.id, submission: { file: zip_file } }, format: :json } - .not_to change(Submission, :count) + 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') diff --git a/spec/fixtures/submission_uploads/empty.zip b/spec/fixtures/submission_uploads/empty.zip new file mode 100644 index 0000000000000000000000000000000000000000..15cb0ecb3e219d1701294bfdf0fe3f5cb5d208e7 GIT binary patch literal 22 NcmWIWW@Tf*000g10H*)| literal 0 HcmV?d00001 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