diff --git a/Gemfile b/Gemfile index 540f19cb5..8fa1f42f2 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,8 @@ gem 'find_with_order' # For handling zip file uploads and extraction gem 'rubyzip' +gem 'openid_connect' +gem 'rack-attack' group :development, :test do gem 'debug', platforms: %i[mri mingw x64_mingw] diff --git a/Gemfile.lock b/Gemfile.lock index aac9f8c1a..caef7c813 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,11 +79,14 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) ast (2.4.3) + attr_required (1.0.2) base64 (0.3.0) bcrypt (3.1.20) benchmark (0.4.1) bigdecimal (3.2.3) + bindata (2.5.1) bootsnap (1.18.6) msgpack (~> 1.2) builder (3.3.0) @@ -134,6 +137,8 @@ GEM docile (1.4.1) domain_name (0.6.20240107) drb (2.2.3) + email_validator (2.2.4) + activemodel erb (5.0.2) erubi (1.13.1) factory_bot (6.5.5) @@ -147,6 +152,8 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-http-cache (2.5.1) faraday (>= 0.8) faraday-net_http (3.4.1) @@ -174,6 +181,13 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.15.0) + json-jwt (1.17.0) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects json-schema (5.2.2) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -238,6 +252,19 @@ GEM faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) + openid_connect (2.3.1) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) @@ -257,9 +284,18 @@ GEM nio4r (~> 2.0) racc (1.8.1) rack (3.2.1) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-cors (3.0.0) logger rack (>= 3.0.14) + rack-oauth2 (2.3.0) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -374,6 +410,11 @@ GEM sqlite3 (1.7.3) mini_portile2 (~> 2.8.0) stringio (3.1.7) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects sync (0.5.0) term-ansicolor (1.11.3) tins (~> 1) @@ -395,6 +436,13 @@ GEM unicode-emoji (4.1.0) uri (1.0.3) useragent (0.16.11) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -435,9 +483,11 @@ DEPENDENCIES mutex_m mysql2 (~> 0.5.7) observer + openid_connect ostruct psych (~> 5.2) puma (~> 6.4) + rack-attack rack-cors rails (~> 8.0, >= 8.0.1) rspec-rails diff --git a/app/controllers/authentication_controller.rb b/app/controllers/authentication_controller.rb index 696c4ab21..8f855afab 100644 --- a/app/controllers/authentication_controller.rb +++ b/app/controllers/authentication_controller.rb @@ -1,6 +1,3 @@ -# app/controllers/authentication_controller.rb -require 'json_web_token' - class AuthenticationController < ApplicationController skip_before_action :authenticate_request! @@ -8,9 +5,7 @@ class AuthenticationController < ApplicationController def login user = User.find_by(name: params[:user_name]) || User.find_by(email: params[:user_name]) if user&.authenticate(params[:password]) - payload = { id: user.id, name: user.name, full_name: user.full_name, role: user.role.name, - institution_id: user.institution.id } - token = JsonWebToken.encode(payload, 24.hours.from_now) + token = user.generate_jwt render json: { token: }, status: :ok else render json: { error: 'Invalid username / password' }, status: :unauthorized diff --git a/app/controllers/oidc_login_controller.rb b/app/controllers/oidc_login_controller.rb new file mode 100644 index 000000000..e3d1dbdac --- /dev/null +++ b/app/controllers/oidc_login_controller.rb @@ -0,0 +1,70 @@ +class OidcLoginController < ApplicationController + # OIDC login does not require an existing session — this is how users sign in. + skip_before_action :authenticate_request! + + # Unknown provider keys from the frontend return 404 with the specific message. + rescue_from OidcConfig::ProviderNotFound do |e| + render_error e.message, status: :not_found + end + + # IdP discovery or token endpoint unreachable — surface as 502 Bad Gateway + # so the frontend can distinguish provider outages from user-facing errors. + rescue_from OpenIDConnect::Discovery::DiscoveryFailed, Rack::OAuth2::Client::Error do |e| + render_error "Provider communication failed: #{e.message}", status: :bad_gateway + end + + # Missing required params (from params.require) return 400 with the param name. + rescue_from ActionController::ParameterMissing do |e| + render_error e.message, status: :bad_request + end + + # GET /auth/providers + # Returns the list of configured OIDC providers for the frontend dropdown. + # Only public info (id, name) — no secrets or endpoint URLs. + def providers + render json: OidcConfig.public_list + end + + # POST /auth/client-select + # Initiates an OIDC login. The frontend calls this with the chosen provider + # and the user's Expertiza username, and receives an authorization URL to + # redirect the browser to. Username is required here because the IdP only + # returns an email claim, and Expertiza emails are not unique across accounts. + # Candidate for rate limiting — it creates a DB row and triggers provider discovery. + def client_select + provider = params.require(:provider) + username = params.require(:username) + + authorization_uri = OidcRequest.authorization_uri_for!( + provider_key: provider, + username: username + ) + render json: { redirect_uri: authorization_uri } + end + + # POST /auth/callback + # Completes an OIDC login after the IdP redirects the user back to the frontend. + # Consumes the stored OidcRequest by state, verifies the ID token, matches a + # local user, and issues a session JWT. + # Returns a generic 401 "Authentication failed" for all verification or matching + # failures to avoid leaking which check failed (state, token, user match, etc.). + def callback + state = params.require(:state) + code = params.require(:code) + + oidc_request = OidcRequest.consume_recent_by_state!(state) + user = oidc_request.authenticate_user!(code: code) + render json: { token: user.generate_jwt }, status: :ok + rescue ActiveRecord::RecordNotFound, OidcRequest::AuthenticationError, + OpenIDConnect::ResponseObject::IdToken::InvalidToken, + OidcConfig::ProviderNotFound + render_error "Authentication failed", status: :unauthorized + end + + private + + # Standardizes the JSON error response shape across all endpoints. + def render_error(message, status:) + render json: { error: message }, status: status + end +end diff --git a/app/jobs/cleanup_stale_oidc_requests_job.rb b/app/jobs/cleanup_stale_oidc_requests_job.rb new file mode 100644 index 000000000..147f55b70 --- /dev/null +++ b/app/jobs/cleanup_stale_oidc_requests_job.rb @@ -0,0 +1,7 @@ +class CleanupStaleOidcRequestsJob < ApplicationJob + queue_as :default + + def perform + OidcRequest.delete_stale + end +end diff --git a/app/models/oidc_config.rb b/app/models/oidc_config.rb new file mode 100644 index 000000000..b9e7bfe22 --- /dev/null +++ b/app/models/oidc_config.rb @@ -0,0 +1,90 @@ +class OidcConfig + class ProviderNotFound < StandardError; end + class InvalidConfiguration < StandardError; end + + CONFIG_FILE = Rails.root.join("config", "oidc_providers.yml").freeze + REQUIRED_KEYS = %w[display_name issuer client_id client_secret redirect_uri].freeze + + # Loads, parses, and validates the OIDC provider YAML file. + # Memoized per process — call reload! to force a re-read. + # In production, invalid config raises InvalidConfiguration to block startup. + # In other environments, invalid providers are skipped with a warning. + def self.providers + @providers ||= begin + yaml = ERB.new(File.read(CONFIG_FILE)).result + parsed = YAML.safe_load(yaml, permitted_classes: [], aliases: true) + + unless parsed.is_a?(Hash) + handle_invalid("OIDC config: expected top-level mapping in #{CONFIG_FILE}, got #{parsed.class}") + parsed = {} + end + + providers = parsed["providers"] || {} + unless providers.is_a?(Hash) + handle_invalid("OIDC config: expected 'providers' to be a mapping in #{CONFIG_FILE}, got #{providers.class}") + providers = {} + end + + validate!(providers) + end + end + + # Looks up a provider config by its key (e.g. "google-ncsu"). + # Raises ProviderNotFound if the key is not configured. + def self.find(provider_key) + providers.fetch(provider_key) do + raise ProviderNotFound, "Unknown OIDC provider: #{provider_key}" + end + end + + # Returns the list of providers safe to expose to the frontend. + # Only includes display information — never secrets or endpoint URLs. + def self.public_list + providers.map { |key, cfg| { id: key, name: cfg["display_name"] } } + end + + # Clears the memoized config so the next call to `providers` re-reads the YAML file. + # Primarily useful for tests and hot-reloading in development. + def self.reload! + @providers = nil + end + + # Parses the provider's scopes string (whitespace or comma-separated) into an array. + # Falls back to the default OIDC scopes if none are configured. + def self.scopes_for(provider) + raw = provider["scopes"] + list = case raw + when Array then raw.map { |s| s.to_s.strip } + when String then raw.split(/[\s,]+/) + else [] + end + list.reject(&:blank?).presence || %w[openid email profile] + end + + # Removes providers missing any REQUIRED_KEYS. + # In production, raises InvalidConfiguration to prevent startup with misconfigured providers. + # In other environments, logs a warning and skips the provider. + def self.validate!(providers) + providers.reject! do |key, cfg| + unless cfg.is_a?(Hash) + handle_invalid("OIDC provider '#{key}' invalid: expected mapping, got #{cfg.class}") + next true + end + + missing = REQUIRED_KEYS.select { |k| cfg[k].blank? } + if missing.any? + handle_invalid("OIDC provider '#{key}' invalid: missing #{missing.join(', ')}") + true + end + end + providers + end + + # Raises in production to block startup; logs a warning elsewhere. + def self.handle_invalid(message) + raise InvalidConfiguration, message if Rails.env.production? + Rails.logger.warn(message) + end + + private_class_method :validate!, :handle_invalid +end \ No newline at end of file diff --git a/app/models/oidc_request.rb b/app/models/oidc_request.rb new file mode 100644 index 000000000..25d55291a --- /dev/null +++ b/app/models/oidc_request.rb @@ -0,0 +1,140 @@ +class OidcRequest < ApplicationRecord + # Raised for any authentication failure (missing claim, unverified email, no matching user). + # The controller rescues this and returns a generic 401 to avoid leaking which check failed. + class AuthenticationError < StandardError; end + + # How long a newly-created auth request is considered valid before being treated as stale. + VALIDITY_WINDOW = 5.minutes + + # Probability (0.0–1.0) of triggering a stale-cleanup job on each successful create. + # Amortizes cleanup cost without requiring a dedicated scheduler. + CLEANUP_PROBABILITY = 0.10 + + after_create :maybe_enqueue_stale_cleanup + + # Deletes all auth requests older than the validity window. + # Called by CleanupStaleOidcRequestsJob; safe to invoke directly for manual cleanup. + def self.delete_stale + where("created_at <= ?", VALIDITY_WINDOW.ago).delete_all + end + + # Atomically finds and destroys the request matching the given state to prevent + # replay attacks. Only considers requests within the validity window. + # Raises ActiveRecord::RecordNotFound if no matching recent request exists. + def self.consume_recent_by_state!(state) + transaction do + request = where("created_at > ?", VALIDITY_WINDOW.ago).lock.find_by!(state: state) + request.destroy! + request + end + end + + # Starts an OIDC login for the given provider and username: + # - Performs provider discovery + # - Generates fresh state, nonce, and PKCE values + # - Persists an OidcRequest row so the callback can verify and consume it + # Returns the authorization URL the frontend should redirect the user to. + # PKCE is always sent; providers that don't support it will ignore the extra params. + def self.authorization_uri_for!(provider_key:, username:) + provider = OidcConfig.find(provider_key) + discovery = OpenIDConnect::Discovery::Provider::Config.discover!(provider["issuer"]) + client = new_client(provider, discovery: discovery) + + state = SecureRandom.hex(32) + nonce = SecureRandom.hex(32) + code_verifier = SecureRandom.urlsafe_base64(64, false) + code_challenge = Base64.urlsafe_encode64( + Digest::SHA256.digest(code_verifier), padding: false + ) + + create!( + state: state, + nonce: nonce, + code_verifier: code_verifier, + provider: provider_key, + username: username + ) + + client.authorization_uri( + scope: OidcConfig.scopes_for(provider), + state: state, + nonce: nonce, + code_challenge: code_challenge, + code_challenge_method: "S256" + ) + end + + # Exchanges the authorization code for tokens and verifies the ID token: + # - Signature via provider JWKS + # - Issuer, audience (client_id), and nonce claims + # - email_verified claim must be explicitly true + # Returns the user's email from the ID token claims. + # Raises AuthenticationError if the email is unverified or missing. + def verified_email_from_code!(provider_key:, code:) + provider = OidcConfig.find(provider_key) + discovery = OpenIDConnect::Discovery::Provider::Config.discover!(provider["issuer"]) + client = self.class.new_client(provider, discovery: discovery) + + client.authorization_code = code + access_token = client.access_token!(code_verifier: code_verifier) + + id_token = OpenIDConnect::ResponseObject::IdToken.decode( + access_token.id_token, + discovery.jwks + ) + id_token.verify!( + issuer: discovery.issuer, + client_id: provider["client_id"], + nonce: nonce + ) + + claims = id_token.raw_attributes + + raise AuthenticationError, "Email not verified by provider" unless claims["email_verified"] == true + + email = claims["email"].to_s.strip + raise AuthenticationError, "Email missing from provider response" if email.blank? + + email + end + + # Verifies the OIDC callback and resolves it to a local user. + # Matches on both the stored username and the verified email claim because + # emails are not unique in Expertiza. Whitespace and case are normalized on + # both sides to handle legacy data with inconsistent formatting. + # Raises AuthenticationError if no matching user is found. + def authenticate_user!(code:) + email = verified_email_from_code!(provider_key: provider, code: code) + raise AuthenticationError, "No email claim in ID token" if email.blank? + raise AuthenticationError, "No username associated with this request" if username.blank? + + normalized_username = username.to_s.strip.downcase + normalized_email = email.to_s.strip.downcase + + User.where( + "LOWER(TRIM(name)) = ? AND LOWER(TRIM(email)) = ?", + normalized_username, normalized_email + ).first || raise(AuthenticationError, "No account found for #{username} with email #{email}") + end + + # Internal: builds an OpenIDConnect::Client from provider config and discovery. + # Used by both authorization_uri_for! and verified_email_from_code!. + def self.new_client(provider, discovery:) + OpenIDConnect::Client.new( + identifier: provider["client_id"], + secret: provider["client_secret"], + redirect_uri: provider["redirect_uri"], + authorization_endpoint: discovery.authorization_endpoint, + token_endpoint: discovery.token_endpoint, + userinfo_endpoint: discovery.userinfo_endpoint + ) + end + + private + + # after_create callback. Probabilistically enqueues a cleanup job for stale rows. + # Runs inline since it's non-blocking (just pushes to the job queue). + def maybe_enqueue_stale_cleanup + CleanupStaleOidcRequestsJob.perform_later if rand < CLEANUP_PROBABILITY + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 0e77e25dc..566e5cadc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'json_web_token' + class User < ApplicationRecord has_secure_password after_initialize :set_defaults @@ -149,8 +151,16 @@ def set_defaults self.etc_icons_on_homepage ||= true end - def generate_jwt - JWT.encode({ id: id, exp: 60.days.from_now.to_i }, Rails.application.credentials.secret_key_base) + # Return a signed jwt with a payload for frontend session creation + def generate_jwt(expiry = 24.hours.from_now) + payload = { + id: id, + name: name, + full_name: full_name, + role: role&.name, + institution_id: institution&.id + } + JsonWebToken.encode(payload, expiry) end end diff --git a/config/initializers/oidc.rb b/config/initializers/oidc.rb new file mode 100644 index 000000000..fe5b381e7 --- /dev/null +++ b/config/initializers/oidc.rb @@ -0,0 +1,5 @@ +Rails.application.config.after_initialize do + OidcConfig.providers +rescue Errno::ENOENT + Rails.logger.info("OIDC config file not found; OIDC disabled") +end \ No newline at end of file diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 000000000..32c0ea989 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Rack::Attack middleware configuration for OIDC login rate limiting. +# +# Cache store strategy: +# test/development — MemoryStore: always available, no Redis required. +# test.rb sets Rails.cache to :null_store (counters would never accumulate), +# and development uses :null_store by default, so we use MemoryStore directly. +# production — Rails.cache (Redis): shared across requests within a process. +# Note: configured with raise_errors: false in this app, so a Redis outage +# will silently drop counters and cause throttles to fail open. Monitor +# Redis availability accordingly. +Rack::Attack.cache.store = if Rails.env.production? + Rails.cache +else + ActiveSupport::Cache::MemoryStore.new +end + +# ── Throttles ───────────────────────────────────────────────────────────────── + +# Limit authorization initiation to 5 requests per minute per IP. +# This endpoint creates a DB row and triggers OIDC provider discovery, making +# it expensive and a prime target for abuse. +Rack::Attack.throttle("oidc/client-select/ip", limit: 5, period: 60) do |req| + req.ip if req.post? && req.path == "/auth/client-select" +end + +# Limit callback completions to 10 requests per minute per IP. +# Protects against code-reuse replay attempts and brute-force state guessing. +Rack::Attack.throttle("oidc/callback/ip", limit: 10, period: 60) do |req| + req.ip if req.post? && req.path == "/auth/callback" +end + +# ── Throttled response ───────────────────────────────────────────────────────── + +# Return JSON-formatted 429 responses consistent across OidcLoginController. +Rack::Attack.throttled_responder = lambda do |req| + match_data = req.env["rack.attack.match_data"] + retry_after = match_data ? (match_data[:period] - (Time.now.to_i % match_data[:period])) : 60 + + [ + 429, + { + "Content-Type" => "application/json", + "Retry-After" => retry_after.to_s + }, + [{ error: "Rate limit exceeded. Try again later." }.to_json] + ] +end diff --git a/config/oidc_providers.yml b/config/oidc_providers.yml new file mode 100644 index 000000000..e3d0244a5 --- /dev/null +++ b/config/oidc_providers.yml @@ -0,0 +1,28 @@ +providers: + google-ncsu: + display_name: Google NCSU + issuer: https://accounts.google.com + client_id: <%= ENV['GOOG_CLIENT_ID'] %> + client_secret: <%= ENV['GOOG_CLIENT_SECRET'] %> + redirect_uri: <%= ENV['GOOG_REDIRECT_URI'] %> + +# Add more providers here (values below are examples, not real credentials): + +# Provider key used to fetch config +# okta: +# Name shown to users in the login dropdown +# display_name: Okta + +# OIDC issuer URL, used to fetch .well-known/openid-configuration +# issuer: https://dev-123456.okta.com + +# OAuth client credentials, register with the provider to obtain these +# client_id: 0oa1b2c3d4e5f6g7h8i9 +# client_secret: AbCdEfGhIjKlMnOpQrStUvWxYz0123456789 + +# Where the IdP redirects after authentication, must match provider registration +# This should probably point to the frontend callback endpoint +# redirect_uri: http://localhost:3000/auth/callback + +# Space-separated OIDC scopes, defaults to "openid email profile" if omitted +# scopes: openid email profile \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 57559d007..32724f58d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -214,4 +214,8 @@ resources :assignments do resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] end + + get 'auth/providers', to: 'oidc_login#providers' + post 'auth/client-select', to: 'oidc_login#client_select' + post 'auth/callback', to: 'oidc_login#callback' end diff --git a/db/migrate/20260407003623_create_auth_requests.rb b/db/migrate/20260407003623_create_auth_requests.rb new file mode 100644 index 000000000..e334af1b5 --- /dev/null +++ b/db/migrate/20260407003623_create_auth_requests.rb @@ -0,0 +1,13 @@ +class CreateAuthRequests < ActiveRecord::Migration[8.0] + def change + create_table :auth_requests do |t| + t.string :state + t.string :nonce + t.string :code_verifier + t.string :provider + + t.timestamps + end + add_index :auth_requests, :state + end +end diff --git a/db/migrate/20260411120000_rename_auth_requests_to_oidc_requests.rb b/db/migrate/20260411120000_rename_auth_requests_to_oidc_requests.rb new file mode 100644 index 000000000..7db5993a0 --- /dev/null +++ b/db/migrate/20260411120000_rename_auth_requests_to_oidc_requests.rb @@ -0,0 +1,17 @@ +class RenameAuthRequestsToOidcRequests < ActiveRecord::Migration[8.0] + def up + rename_table :auth_requests, :oidc_requests + + if index_name_exists?(:oidc_requests, 'index_auth_requests_on_state') + rename_index :oidc_requests, 'index_auth_requests_on_state', 'index_oidc_requests_on_state' + end + end + + def down + rename_table :oidc_requests, :auth_requests + + if index_name_exists?(:auth_requests, 'index_oidc_requests_on_state') + rename_index :auth_requests, 'index_oidc_requests_on_state', 'index_auth_requests_on_state' + end + end +end diff --git a/db/migrate/20260416184458_add_username_to_oidc_requests.rb b/db/migrate/20260416184458_add_username_to_oidc_requests.rb new file mode 100644 index 000000000..83c8907cb --- /dev/null +++ b/db/migrate/20260416184458_add_username_to_oidc_requests.rb @@ -0,0 +1,5 @@ +class AddUsernameToOidcRequests < ActiveRecord::Migration[8.0] + def change + add_column :oidc_requests, :username, :string + end +end diff --git a/db/migrate/20260421223609_enforce_oidc_requests_constraints.rb b/db/migrate/20260421223609_enforce_oidc_requests_constraints.rb new file mode 100644 index 000000000..f0833481e --- /dev/null +++ b/db/migrate/20260421223609_enforce_oidc_requests_constraints.rb @@ -0,0 +1,12 @@ +class EnforceOidcRequestsConstraints < ActiveRecord::Migration[8.0] + def change + change_column_null :oidc_requests, :state, false + change_column_null :oidc_requests, :nonce, false + change_column_null :oidc_requests, :code_verifier, false + change_column_null :oidc_requests, :provider, false + change_column_null :oidc_requests, :username, false + + remove_index :oidc_requests, :state + add_index :oidc_requests, :state, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index cddbe12c6..54c678a37 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_13_064334) do +ActiveRecord::Schema[8.0].define(version: 2026_04_21_223609) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -240,6 +240,17 @@ t.datetime "updated_at", null: false end + create_table "oidc_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "state", null: false + t.string "nonce", null: false + t.string "code_verifier", null: false + t.string "provider", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "username", null: false + t.index ["state"], name: "index_oidc_requests_on_state", unique: true + end + create_table "participants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id" t.datetime "created_at", null: false @@ -456,9 +467,9 @@ add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" + add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "invitations", "participants", column: "from_id" add_foreign_key "invitations", "participants", column: "to_id" - add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "duties" add_foreign_key "participants", "join_team_requests" diff --git a/spec/factories.rb b/spec/factories.rb index 1556b02b2..7c57f8b65 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true FactoryBot.define do + factory :oidc_request do + sequence(:state) { |n| "state-#{n}-#{SecureRandom.hex(8)}" } + nonce { SecureRandom.hex(32) } + code_verifier { SecureRandom.urlsafe_base64(64) } + provider { "google-ncsu" } + username { "oidcuser" } + end + factory :student_task do assignment { nil } current_stage { "MyString" } diff --git a/spec/models/oidc_config_spec.rb b/spec/models/oidc_config_spec.rb new file mode 100644 index 000000000..c786995e3 --- /dev/null +++ b/spec/models/oidc_config_spec.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe OidcConfig, type: :model do + before do + described_class.reload! + allow(Rails.logger).to receive(:warn) + end + + after do + described_class.reload! + end + + describe '.providers' do + it 'loads providers from YAML and evaluates ERB env vars' do + original_client_id = ENV['GOOG_CLIENT_ID'] + original_client_secret = ENV['GOOG_CLIENT_SECRET'] + begin + ENV['GOOG_CLIENT_ID'] = 'client-id-from-env' + ENV['GOOG_CLIENT_SECRET'] = 'client-secret-from-env' + + yaml = <<~YAML + providers: + google-ncsu: + display_name: Google NCSU + issuer: https://accounts.google.com + client_id: <%= ENV['GOOG_CLIENT_ID'] %> + client_secret: <%= ENV['GOOG_CLIENT_SECRET'] %> + redirect_uri: http://localhost:3000/auth/callback + scopes: openid email profile + YAML + + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(yaml) + + providers = described_class.providers + + expect(providers.keys).to eq(['google-ncsu']) + expect(providers['google-ncsu']['client_id']).to eq('client-id-from-env') + expect(providers['google-ncsu']['client_secret']).to eq('client-secret-from-env') + ensure + ENV['GOOG_CLIENT_ID'] = original_client_id + ENV['GOOG_CLIENT_SECRET'] = original_client_secret + end + end + + it 'memoizes results until reload! is called' do + first_yaml = <<~YAML + providers: + first: + display_name: First + issuer: https://issuer.example.com + client_id: id-1 + client_secret: secret-1 + redirect_uri: http://localhost:3000/auth/callback + YAML + + second_yaml = <<~YAML + providers: + second: + display_name: Second + issuer: https://issuer-2.example.com + client_id: id-2 + client_secret: secret-2 + redirect_uri: http://localhost:3000/auth/callback + YAML + + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(first_yaml) + + expect(described_class.providers.keys).to eq(['first']) + + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(second_yaml) + expect(described_class.providers.keys).to eq(['first']) + + described_class.reload! + expect(described_class.providers.keys).to eq(['second']) + end + + it 'skips providers missing required keys and warns' do + yaml = <<~YAML + providers: + valid: + display_name: Valid + issuer: https://issuer.example.com + client_id: id + client_secret: secret + redirect_uri: http://localhost:3000/auth/callback + scopes: openid email profile + no_scopes: + display_name: No Scopes + issuer: https://issuer.example.com + client_id: id + client_secret: secret + redirect_uri: http://localhost:3000/auth/callback + missing_secret: + display_name: Missing Secret + issuer: https://issuer.example.com + client_id: id + redirect_uri: http://localhost:3000/auth/callback + YAML + + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(yaml) + + providers = described_class.providers + + expect(providers.keys).to contain_exactly('valid', 'no_scopes') + expect(Rails.logger).to have_received(:warn).with(/missing_secret.*missing client_secret/) + end + + it 'returns an empty hash when no providers key exists' do + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return('{}') + + expect(described_class.providers).to eq({}) + end + + it 'returns an empty hash when YAML is empty (safe_load returns nil)' do + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return('') + + expect(described_class.providers).to eq({}) + end + + it 'returns an empty hash when providers key is null' do + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return("providers: null\n") + + expect(described_class.providers).to eq({}) + end + + it 'returns an empty hash when the top-level YAML is not a Hash' do + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(<<~YAML) + - item_one + - item_two + YAML + + expect(described_class.providers).to eq({}) + expect(Rails.logger).to have_received(:warn).with(/expected top-level mapping/) + end + + it 'returns an empty hash when the providers value is not a Hash' do + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(<<~YAML) + providers: + - item_one + - item_two + YAML + + expect(described_class.providers).to eq({}) + expect(Rails.logger).to have_received(:warn).with(/expected 'providers' to be a mapping/) + end + + it 'supports YAML aliases in provider definitions' do + yaml = <<~YAML + providers: + google: + &base + display_name: Google + issuer: https://accounts.google.com + client_id: id + client_secret: secret + redirect_uri: http://localhost:3000/auth/callback + google-copy: + <<: *base + display_name: Google Copy + YAML + + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(yaml) + + providers = described_class.providers + + expect(providers.keys).to contain_exactly('google', 'google-copy') + expect(providers['google-copy']['issuer']).to eq('https://accounts.google.com') + end + end + + describe '.find' do + it 'returns a provider config by key' do + allow(described_class).to receive(:providers).and_return( + 'google-ncsu' => { + 'display_name' => 'Google NCSU', + 'issuer' => 'https://accounts.google.com', + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect_uri' => 'http://localhost:3000/auth/callback' + } + ) + + expect(described_class.find('google-ncsu')['display_name']).to eq('Google NCSU') + end + + it 'raises ProviderNotFound for unknown provider keys' do + allow(described_class).to receive(:providers).and_return({}) + + expect { described_class.find('unknown') } + .to raise_error(OidcConfig::ProviderNotFound, 'Unknown OIDC provider: unknown') + end + end + + describe '.public_list' do + it 'returns only id and name for each provider' do + allow(described_class).to receive(:providers).and_return( + 'google-ncsu' => { + 'display_name' => 'Google NCSU', + 'issuer' => 'https://accounts.google.com', + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect_uri' => 'http://localhost:3000/auth/callback' + } + ) + + expect(described_class.public_list).to eq([{ id: 'google-ncsu', name: 'Google NCSU' }]) + end + end + + describe '.scopes_for' do + it 'parses whitespace-delimited scope strings' do + provider = { 'scopes' => 'openid email profile custom' } + + expect(described_class.scopes_for(provider)).to eq(%w[openid email profile custom]) + end + + it 'parses comma-delimited scope strings' do + provider = { 'scopes' => 'openid,email,profile' } + + expect(described_class.scopes_for(provider)).to eq(%w[openid email profile]) + end + + it 'falls back to default scopes when scopes is nil' do + provider = { 'scopes' => nil } + + expect(described_class.scopes_for(provider)).to eq(%w[openid email profile]) + end + + it 'falls back to default scopes when scopes key is absent' do + provider = {} + + expect(described_class.scopes_for(provider)).to eq(%w[openid email profile]) + end + + it 'parses mixed comma and whitespace delimiters' do + provider = { 'scopes' => 'openid, email, profile' } + + expect(described_class.scopes_for(provider)).to eq(%w[openid email profile]) + end + end + + describe 'production behavior' do + before do + allow(Rails.env).to receive(:production?).and_return(true) + end + + it 'raises InvalidConfiguration when a provider is missing required keys' do + yaml = <<~YAML + providers: + bad: + display_name: Bad + issuer: https://issuer.example.com + client_id: id + redirect_uri: http://localhost:3000/auth/callback + YAML + + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return(yaml) + + expect { described_class.providers } + .to raise_error(OidcConfig::InvalidConfiguration, /missing client_secret/) + end + + it 'raises InvalidConfiguration when the top-level YAML is not a Hash' do + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return("- item_one\n") + + expect { described_class.providers } + .to raise_error(OidcConfig::InvalidConfiguration, /expected top-level mapping/) + end + + it 'raises InvalidConfiguration when the providers value is not a Hash' do + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(described_class::CONFIG_FILE).and_return("providers:\n - item_one\n") + + expect { described_class.providers } + .to raise_error(OidcConfig::InvalidConfiguration, /expected 'providers' to be a mapping/) + end + end +end diff --git a/spec/models/oidc_request_spec.rb b/spec/models/oidc_request_spec.rb new file mode 100644 index 000000000..e56b09563 --- /dev/null +++ b/spec/models/oidc_request_spec.rb @@ -0,0 +1,342 @@ +require 'rails_helper' + +RSpec.describe OidcRequest, type: :model do + include RolesHelper + + let(:provider) do + { + 'display_name' => 'Google NCSU', + 'issuer' => 'https://accounts.google.com', + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'redirect_uri' => 'http://localhost:3000/auth/callback', + 'scopes' => 'openid email profile' + } + end + + let(:discovery) do + instance_double( + OpenIDConnect::Discovery::Provider::Config::Response, + authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + token_endpoint: 'https://oauth2.googleapis.com/token', + userinfo_endpoint: 'https://openidconnect.googleapis.com/v1/userinfo', + issuer: 'https://accounts.google.com', + jwks: instance_double(JSON::JWK::Set) + ) + end + + let(:client) { instance_double(OpenIDConnect::Client) } + + before do + allow(OpenIDConnect::Discovery::Provider::Config).to receive(:discover!) + .with('https://accounts.google.com').and_return(discovery) + allow(OpenIDConnect::Client).to receive(:new).and_return(client) + end + + # ─── Helpers ──────────────────────────────────────────────────────── + + def create_request(state: 'state', nonce: 'nonce', verifier: 'verifier', + provider: 'google-ncsu', username: 'oidcuser') + described_class.create!( + state: state, + nonce: nonce, + code_verifier: verifier, + provider: provider, + username: username + ) + end + + def stub_token_exchange(email:, email_verified: nil) + allow(client).to receive(:authorization_code=) + allow(client).to receive(:access_token!) + .and_return(instance_double(OpenIDConnect::AccessToken, id_token: 'fake.id.token')) + + claims = { 'email' => email } + claims['email_verified'] = email_verified unless email_verified.nil? + + id_token_obj = instance_double( + OpenIDConnect::ResponseObject::IdToken, + raw_attributes: claims + ) + allow(id_token_obj).to receive(:verify!) + allow(OpenIDConnect::ResponseObject::IdToken).to receive(:decode).and_return(id_token_obj) + end + + # ─── .consume_recent_by_state! ────────────────────────────────────── + + describe '.consume_recent_by_state!' do + let!(:recent_request) { create_request(state: 'recent-state') } + + it 'returns and destroys a recent request matching state' do + consumed = described_class.consume_recent_by_state!('recent-state') + + expect(consumed.id).to eq(recent_request.id) + expect(described_class.find_by(id: recent_request.id)).to be_nil + end + + it 'raises RecordNotFound for unknown state' do + expect { described_class.consume_recent_by_state!('missing-state') } + .to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises RecordNotFound for expired requests' do + recent_request.update_columns(created_at: 10.minutes.ago) + + expect { described_class.consume_recent_by_state!('recent-state') } + .to raise_error(ActiveRecord::RecordNotFound) + expect(described_class.find_by(id: recent_request.id)).to be_present + end + + it 'prevents replay by destroying the row on consumption' do + described_class.consume_recent_by_state!('recent-state') + + expect { described_class.consume_recent_by_state!('recent-state') } + .to raise_error(ActiveRecord::RecordNotFound) + end + end + + # ─── .delete_stale ────────────────────────────────────────────────── + + describe '.delete_stale' do + it 'deletes rows older than the validity window and preserves fresh rows' do + fresh = create_request(state: 'fresh') + stale = create_request(state: 'stale') + stale.update_columns(created_at: 10.minutes.ago) + + described_class.delete_stale + + expect(described_class.find_by(id: fresh.id)).to be_present + expect(described_class.find_by(id: stale.id)).to be_nil + end + + it 'returns 0 when no stale rows exist' do + create_request(state: 'only-fresh') + + expect(described_class.delete_stale).to eq(0) + end + end + + # ─── after_create :maybe_enqueue_stale_cleanup ────────────────────── + + describe 'probabilistic cleanup on create' do + it 'enqueues CleanupStaleOidcRequestsJob when rand falls under the threshold' do + allow_any_instance_of(described_class).to receive(:rand).and_return(0.0) + + expect(CleanupStaleOidcRequestsJob).to receive(:perform_later) + + create_request(state: 'any') + end + + it 'does not enqueue when rand falls above the threshold' do + allow_any_instance_of(described_class).to receive(:rand).and_return(0.99) + + expect(CleanupStaleOidcRequestsJob).not_to receive(:perform_later) + + create_request(state: 'any') + end + end + + # ─── .authorization_uri_for! ──────────────────────────────────────── + + describe '.authorization_uri_for!' do + before do + allow(OidcConfig).to receive(:find).with('google-ncsu').and_return(provider) + end + + it 'creates auth request with username and returns provider authorization URI' do + allow(client).to receive(:authorization_uri) + .with(hash_including(scope: %w[openid email profile], code_challenge_method: 'S256')) + .and_return('https://accounts.google.com/o/oauth2/v2/auth?scope=openid+email+profile') + + expect do + uri = described_class.authorization_uri_for!(provider_key: 'google-ncsu', username: 'oidcuser') + expect(uri).to include('scope=openid+email+profile') + end.to change(described_class, :count).by(1) + + created = described_class.last + expect(created.username).to eq('oidcuser') + expect(created.provider).to eq('google-ncsu') + end + + it 'uses default scopes when provider scopes are missing' do + allow(OidcConfig).to receive(:find).with('google-ncsu') + .and_return(provider.merge('scopes' => nil)) + allow(client).to receive(:authorization_uri) + .with(hash_including(scope: %w[openid email profile], code_challenge_method: 'S256')) + .and_return('https://accounts.google.com/o/oauth2/v2/auth?scope=openid+email+profile') + + described_class.authorization_uri_for!(provider_key: 'google-ncsu', username: 'oidcuser') + end + + it 'raises when creating a request with a duplicate state' do + create_request(state: 'duplicate-state') + + expect { + create_request(state: 'duplicate-state', nonce: 'different-nonce') + }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + + # ─── #verified_email_from_code! ───────────────────────────────────── + + describe '#verified_email_from_code!' do + before do + allow(OidcConfig).to receive(:find).with('google-ncsu').and_return(provider) + end + + let(:oidc_request) { create_request } + + it 'exchanges code, verifies token and returns email' do + stub_token_exchange(email: 'oidcuser@ncsu.edu', email_verified: true) + + email = oidc_request.verified_email_from_code!(provider_key: 'google-ncsu', code: 'authorization-code') + expect(email).to eq('oidcuser@ncsu.edu') + end + + it 'raises InvalidToken when id token verification fails' do + bad_token = instance_double( + OpenIDConnect::ResponseObject::IdToken, + raw_attributes: { 'email' => 'oidcuser@ncsu.edu' } + ) + allow(client).to receive(:authorization_code=) + allow(client).to receive(:access_token!) + .and_return(instance_double(OpenIDConnect::AccessToken, id_token: 'fake.id.token')) + allow(OpenIDConnect::ResponseObject::IdToken).to receive(:decode).and_return(bad_token) + allow(bad_token).to receive(:verify!) + .and_raise(OpenIDConnect::ResponseObject::IdToken::InvalidToken.new('nonce mismatch')) + + expect { + oidc_request.verified_email_from_code!(provider_key: 'google-ncsu', code: 'authorization-code') + }.to raise_error(OpenIDConnect::ResponseObject::IdToken::InvalidToken, /nonce mismatch/) + end + + it 'returns email when email_verified is true' do + stub_token_exchange(email: 'oidcuser@ncsu.edu', email_verified: true) + + email = oidc_request.verified_email_from_code!(provider_key: 'google-ncsu', code: 'authorization-code') + expect(email).to eq('oidcuser@ncsu.edu') + end + + it 'raises AuthenticationError when email_verified claim is absent' do + stub_token_exchange(email: 'oidcuser@ncsu.edu') + + expect { oidc_request.verified_email_from_code!(provider_key: 'google-ncsu', code: 'authorization-code') } + .to raise_error(OidcRequest::AuthenticationError, /Email not verified/) + end + + it 'raises AuthenticationError when email_verified is false' do + stub_token_exchange(email: 'oidcuser@ncsu.edu', email_verified: false) + + expect { oidc_request.verified_email_from_code!(provider_key: 'google-ncsu', code: 'authorization-code') } + .to raise_error(OidcRequest::AuthenticationError, /Email not verified/) + end + end + + # ─── #authenticate_user! ──────────────────────────────────────────── + + describe '#authenticate_user!' do + before(:each) do + @roles = create_roles_hierarchy + @institution = Institution.first || Institution.create!(name: "Test Institution") + allow(OidcConfig).to receive(:find).with('google-ncsu').and_return(provider) + end + + let!(:user) do + User.create!( + name: "OidcUser", password: "password", role_id: @roles[:student].id, + full_name: "OIDC User", email: "OidcUser@ncsu.edu", institution: @institution + ) + end + + it 'matches user by username and email' do + oidc_request = create_request(username: 'OidcUser') + stub_token_exchange(email: 'OidcUser@ncsu.edu', email_verified: true) + + result = oidc_request.authenticate_user!(code: 'authorization-code') + expect(result.id).to eq(user.id) + end + + it 'matches user case-insensitively on username' do + oidc_request = create_request(username: 'oidcuser') + stub_token_exchange(email: 'OidcUser@ncsu.edu', email_verified: true) + + result = oidc_request.authenticate_user!(code: 'authorization-code') + expect(result.id).to eq(user.id) + end + + it 'matches user case-insensitively on email' do + oidc_request = create_request(username: 'OidcUser') + stub_token_exchange(email: 'oidcuser@ncsu.edu', email_verified: true) + + result = oidc_request.authenticate_user!(code: 'authorization-code') + expect(result.id).to eq(user.id) + end + + it 'matches user case-insensitively on both username and email' do + oidc_request = create_request(username: 'OIDCUSER') + stub_token_exchange(email: 'OIDCUSER@NCSU.EDU', email_verified: true) + + result = oidc_request.authenticate_user!(code: 'authorization-code') + expect(result.id).to eq(user.id) + end + + it 'raises AuthenticationError when email matches but username does not' do + oidc_request = create_request(username: 'wronguser') + stub_token_exchange(email: 'OidcUser@ncsu.edu', email_verified: true) + + expect { oidc_request.authenticate_user!(code: 'authorization-code') } + .to raise_error(OidcRequest::AuthenticationError, /No account found/) + end + + it 'raises AuthenticationError when username matches but email does not' do + oidc_request = create_request(username: 'OidcUser') + stub_token_exchange(email: 'different@example.com', email_verified: true) + + expect { oidc_request.authenticate_user!(code: 'authorization-code') } + .to raise_error(OidcRequest::AuthenticationError, /No account found/) + end + + it 'raises AuthenticationError when neither username nor email match' do + oidc_request = create_request(username: 'nobody') + stub_token_exchange(email: 'nobody@example.com', email_verified: true) + + expect { oidc_request.authenticate_user!(code: 'authorization-code') } + .to raise_error(OidcRequest::AuthenticationError, /No account found/) + end + + it 'matches user when DB stores values with leading/trailing whitespace' do + user.update_columns(name: ' OidcUser ', email: ' OidcUser@ncsu.edu ') + oidc_request = create_request(username: 'OidcUser') + stub_token_exchange(email: 'OidcUser@ncsu.edu', email_verified: true) + + result = oidc_request.authenticate_user!(code: 'authorization-code') + expect(result.id).to eq(user.id) + end + + it 'raises AuthenticationError when email is blank' do + oidc_request = create_request(username: 'OidcUser') + stub_token_exchange(email: '', email_verified: true) + + expect { oidc_request.authenticate_user!(code: 'authorization-code') } + .to raise_error(OidcRequest::AuthenticationError, /Email missing/) + end + end + + # ─── .new_client ──────────────────────────────────────────────────── + + describe '.new_client' do + it 'builds an OpenIDConnect::Client with provider credentials and discovery endpoints' do + expect(OpenIDConnect::Client).to receive(:new).with( + identifier: 'test-client-id', + secret: 'test-client-secret', + redirect_uri: 'http://localhost:3000/auth/callback', + authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + token_endpoint: 'https://oauth2.googleapis.com/token', + userinfo_endpoint: 'https://openidconnect.googleapis.com/v1/userinfo' + ).and_return(client) + + result = described_class.new_client(provider, discovery: discovery) + expect(result).to eq(client) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 000000000..96269b187 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User, type: :model do + describe '#generate_jwt' do + it 'encodes the user attributes into a jwt' do + user = create( + :user, + name: 'jdoe', + email: 'jdoe@example.com', + full_name: 'John Doe' + ) + expiry = 2.hours.from_now + + token = user.generate_jwt(expiry) + payload = JsonWebToken.decode(token) + + # Aligns with frontend expectations + expect(payload[:id]).to eq(user.id) + expect(payload[:name]).to eq(user.name) + expect(payload[:full_name]).to eq(user.full_name) + expect(payload[:role]).to eq(user.role.name) + expect(payload[:institution_id]).to eq(user.institution.id) + expect(payload[:exp]).to eq(expiry.to_i) + end + it 'defaults to 24 hour expiry' do + user = create(:user) + token = user.generate_jwt + payload = JsonWebToken.decode(token) + + expect(payload[:exp]).to be_within(5).of(24.hours.from_now.to_i) + end + it 'raises an error when the token signature is invalid' do + user = create(:user) + token = user.generate_jwt + tampered_token = token.chop + + expect { JsonWebToken.decode(tampered_token) }.to raise_error(JWT::DecodeError) + end + end +end diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb index 7441c7799..8755c4b07 100644 --- a/spec/requests/authentication_spec.rb +++ b/spec/requests/authentication_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'swagger_helper' -require 'json_web_token' RSpec.describe AuthenticationController, type: :request do before(:all) do @@ -13,6 +12,7 @@ post 'Logs in a user' do tags 'Authentication' consumes 'application/json' + security [] parameter name: :credentials, in: :body, schema: { type: :object, properties: { @@ -38,9 +38,6 @@ end let(:credentials) { { user_name: user.name, password: 'password' } } - let(:token) { JsonWebToken.encode({id: user.id}) } - let(:Authorization) { "Bearer #{token}" } - let(:headers) { { "Authorization" => "Bearer #{token}" } } run_test! do |response| json_response = JSON.parse(response.body) token = json_response['token'] @@ -71,11 +68,8 @@ ) end let(:credentials) { { user_name: user.name, password: 'wrongpassword' } } - let(:token) { JsonWebToken.encode({id: user.id}) } - let(:Authorization) { "Bearer #{token}" } - let(:headers) { { "Authorization" => "Bearer #{token}" } } run_test! end end end -end +end \ No newline at end of file diff --git a/spec/requests/oidc_login_spec.rb b/spec/requests/oidc_login_spec.rb new file mode 100644 index 000000000..d8ab0ba52 --- /dev/null +++ b/spec/requests/oidc_login_spec.rb @@ -0,0 +1,488 @@ +# frozen_string_literal: true + +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe OidcLoginController, type: :request do + before(:each) do + @roles = create_roles_hierarchy + @institution = Institution.first || Institution.create!(name: "Test Institution") + end + + let(:provider_cfg) do + { + "display_name" => "Google NCSU", + "issuer" => "https://accounts.google.com", + "client_id" => "test-client-id", + "client_secret" => "test-client-secret", + "redirect_uri" => "http://localhost:3000/auth/callback", + "scopes" => "openid email profile" + } + end + + def stub_provider_config(provider_key = "google-ncsu") + allow(OidcConfig).to receive(:find).with(provider_key).and_return(provider_cfg) + end + + def stub_discovery + discovery = instance_double( + OpenIDConnect::Discovery::Provider::Config::Response, + authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", + token_endpoint: "https://oauth2.googleapis.com/token", + userinfo_endpoint: "https://openidconnect.googleapis.com/v1/userinfo", + issuer: "https://accounts.google.com", + jwks: instance_double(JSON::JWK::Set) + ) + allow(OpenIDConnect::Discovery::Provider::Config).to receive(:discover!) + .with("https://accounts.google.com").and_return(discovery) + discovery + end + + def stub_token_exchange(email:, email_verified: true) + fake_access_token = instance_double(OpenIDConnect::AccessToken, id_token: "fake.id.token") + allow_any_instance_of(OpenIDConnect::Client).to receive(:access_token!).and_return(fake_access_token) + + id_token_obj = instance_double( + OpenIDConnect::ResponseObject::IdToken, + raw_attributes: { "email" => email, "email_verified" => email_verified } + ) + allow(id_token_obj).to receive(:verify!).and_return(true) + allow(OpenIDConnect::ResponseObject::IdToken).to receive(:decode).and_return(id_token_obj) + end + + def create_oidc_request(state:, provider: "google-ncsu", username: "oidcuser") + OidcRequest.create!( + state: state, + nonce: "nonce-#{state}", + code_verifier: "verifier-#{state}", + provider: provider, + username: username + ) + end + + # ─── GET /auth/providers ───────────────────────────────────────────── + + path '/auth/providers' do + get 'List available OIDC providers' do + tags 'OIDC Authentication' + produces 'application/json' + security [] + description 'Returns the list of configured OIDC identity providers that the front end can offer to users.' + + response '200', 'list of providers' do + schema type: :array, + items: { + type: :object, + properties: { + id: { type: :string, example: 'google-ncsu' }, + name: { type: :string, example: 'Google NCSU' } + }, + required: %w[id name] + } + + before do + allow(OidcConfig).to receive(:public_list).and_return([ + { id: "google-ncsu", name: "Google NCSU" } + ]) + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json).to be_an(Array) + expect(json.first).to include("id", "name") + end + end + end + end + + # ─── POST /auth/client-select ─────────────────────────────────────── + + path '/auth/client-select' do + post 'Initiate OIDC login for a chosen provider' do + tags 'OIDC Authentication' + consumes 'application/json' + produces 'application/json' + security [] + description <<~DESC + Accepts a provider key and username, performs OIDC discovery, generates PKCE and state + parameters, persists an OidcRequest for later verification, and returns the provider's + authorization URL that the front end should redirect the user to. + Username is required because emails are not unique in Expertiza. + DESC + + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + provider: { type: :string, example: 'google-ncsu', description: 'Key identifying the OIDC provider' }, + username: { type: :string, example: 'jdoe', description: 'Expertiza username for account matching' } + }, + required: %w[provider username] + } + + response '200', 'authorization redirect URI' do + schema type: :object, + properties: { + redirect_uri: { type: :string, example: 'https://accounts.google.com/o/oauth2/v2/auth?client_id=...&scope=openid+email+profile&state=...&nonce=...' } + }, + required: %w[redirect_uri] + + let(:body) { { provider: "google-ncsu", username: "oidcuser" } } + + before do + allow(OidcRequest).to receive(:authorization_uri_for!) + .with(provider_key: "google-ncsu", username: "oidcuser") + .and_return("https://accounts.google.com/o/oauth2/v2/auth?scope=openid+email+profile") + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["redirect_uri"]).to be_present + end + end + + response '400', 'missing required parameters' do + schema type: :object, + properties: { + error: { type: :string, example: 'param is missing or the value is empty: username' } + }, + required: %w[error] + + let(:body) { { provider: "google-ncsu" } } + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to match(/username/) + end + end + + response '404', 'unknown OIDC provider' do + schema type: :object, + properties: { + error: { type: :string, example: 'Unknown OIDC provider: nonexistent' } + }, + required: %w[error] + + let(:body) { { provider: "nonexistent", username: "oidcuser" } } + + before do + allow(OidcRequest).to receive(:authorization_uri_for!) + .and_raise(OidcConfig::ProviderNotFound, "Unknown OIDC provider: nonexistent") + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to match(/Unknown OIDC provider/) + end + end + + response '502', 'provider discovery failed' do + schema type: :object, + properties: { + error: { type: :string, example: 'Provider communication failed: ...' } + }, + required: %w[error] + + let(:body) { { provider: "google-ncsu", username: "oidcuser" } } + + before do + allow(OidcRequest).to receive(:authorization_uri_for!) + .and_raise(OpenIDConnect::Discovery::DiscoveryFailed.new("Connection refused")) + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to match(/Provider communication failed/) + end + end + end + end + + # ─── POST /auth/callback ──────────────────────────────────────────── + + path '/auth/callback' do + post 'Exchange an OIDC authorization code for a session token' do + tags 'OIDC Authentication' + consumes 'application/json' + produces 'application/json' + security [] + description <<~DESC + Called by the front end after the user is redirected back from the identity provider. + Exchanges the authorization code for tokens, verifies the ID token, and returns + a local JWT session token if the user's username and email match an existing account. + Returns a generic error for all failure modes to avoid information leakage. + DESC + + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + state: { type: :string, description: 'The state parameter returned by the identity provider' }, + code: { type: :string, description: 'The authorization code returned by the identity provider' } + }, + required: %w[state code] + } + + # ── 200 — successful authentication ── + + response '200', 'authenticated user with session token' do + schema type: :object, + properties: { + token: { type: :string, description: 'JWT session token' } + }, + required: %w[token] + + let(:user) do + User.create!( + name: "oidcuser", password: "password", role_id: @roles[:student].id, + full_name: "OIDC User", email: "oidcuser@ncsu.edu", institution: @institution + ) + end + + let(:oidc_request) { create_oidc_request(state: "valid-state", username: "oidcuser") } + let(:body) { { state: oidc_request.state, code: "authorization-code" } } + + before do + user + stub_provider_config + stub_discovery + stub_token_exchange(email: "oidcuser@ncsu.edu") + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["token"]).to be_present + end + end + + # ── 401 — authentication failed (generic for all failure modes) ── + + response '401', 'authentication failed' do + schema type: :object, + properties: { + error: { type: :string, example: 'Authentication failed' } + }, + required: %w[error] + + context 'when no user matches the username and email' do + let(:oidc_request) { create_oidc_request(state: "state-no-user", username: "nobody") } + let(:body) { { state: oidc_request.state, code: "authorization-code" } } + + before do + stub_provider_config + stub_discovery + stub_token_exchange(email: "unknown@example.com") + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to eq("Authentication failed") + end + end + + context 'when email matches but username does not' do + let(:oidc_request) { create_oidc_request(state: "state-wrong-user", username: "wronguser") } + let(:body) { { state: oidc_request.state, code: "authorization-code" } } + + before do + User.create!( + name: "oidcuser", password: "password", role_id: @roles[:student].id, + full_name: "OIDC User", email: "oidcuser@ncsu.edu", institution: @institution + ) + stub_provider_config + stub_discovery + stub_token_exchange(email: "oidcuser@ncsu.edu") + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to eq("Authentication failed") + end + end + + context 'when state is invalid or expired' do + let(:body) { { state: "nonexistent-state", code: "authorization-code" } } + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to eq("Authentication failed") + end + end + + context 'when token verification fails' do + let(:oidc_request) { create_oidc_request(state: "state-bad-token") } + let(:body) { { state: oidc_request.state, code: "authorization-code" } } + + before do + stub_provider_config + stub_discovery + + fake_access_token = instance_double(OpenIDConnect::AccessToken, id_token: "fake.id.token") + allow_any_instance_of(OpenIDConnect::Client).to receive(:access_token!).and_return(fake_access_token) + + allow(OpenIDConnect::ResponseObject::IdToken).to receive(:decode) + .and_raise(OpenIDConnect::ResponseObject::IdToken::InvalidToken.new("invalid signature")) + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to eq("Authentication failed") + end + end + + context 'when the stored provider no longer exists' do + let(:oidc_request) { create_oidc_request(state: "state-missing-provider", provider: "deleted-provider") } + let(:body) { { state: oidc_request.state, code: "authorization-code" } } + + before do + allow(OidcConfig).to receive(:find).with("deleted-provider") + .and_raise(OidcConfig::ProviderNotFound, "Unknown OIDC provider: deleted-provider") + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to eq("Authentication failed") + end + end + end + + # ── 400 — missing required parameters ── + + response '400', 'missing required parameters' do + schema type: :object, + properties: { + error: { type: :string, example: 'param is missing or the value is empty: state' } + }, + required: %w[error] + + let(:body) { { code: "authorization-code" } } + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to match(/state/) + end + end + + # ── 502 — provider communication failed ── + + response '502', 'provider communication failed' do + schema type: :object, + properties: { + error: { type: :string, example: 'Provider communication failed: ...' } + }, + required: %w[error] + + context 'when OIDC discovery fails' do + let(:oidc_request) { create_oidc_request(state: "state-discovery-fail") } + let(:body) { { state: oidc_request.state, code: "authorization-code" } } + + before do + stub_provider_config + + allow(OpenIDConnect::Discovery::Provider::Config).to receive(:discover!) + .and_raise(OpenIDConnect::Discovery::DiscoveryFailed.new("Connection refused")) + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to match(/Provider communication failed/) + end + end + + context 'when token exchange raises an OAuth2 client error' do + let(:oidc_request) { create_oidc_request(state: "state-token-exchange-fail") } + let(:body) { { state: oidc_request.state, code: "authorization-code" } } + + before do + stub_provider_config + stub_discovery + + allow_any_instance_of(OpenIDConnect::Client).to receive(:access_token!) + .and_raise(Rack::OAuth2::Client::Error.new(400, error: "invalid_grant")) + end + + run_test! do |response| + json = JSON.parse(response.body) + expect(json["error"]).to match(/Provider communication failed/) + end + end + end + end + end + + # ─── Rate limiting (Rack::Attack) ─────────────────────────────────────────── + + describe 'rate limiting' do + before do + # Reset the Rack::Attack cache between each test so throttle counters + # don't bleed across examples. + Rack::Attack.cache.store.clear + end + + describe 'POST /auth/client-select' do + let(:valid_params) { { provider: "google-ncsu", username: "oidcuser" }.to_json } + let(:headers) { { "CONTENT_TYPE" => "application/json", "REMOTE_ADDR" => "1.2.3.4" } } + + before do + allow(OidcRequest).to receive(:authorization_uri_for!) + .and_return("https://accounts.google.com/o/oauth2/v2/auth?scope=openid+email+profile") + end + + it 'allows requests within the per-IP limit' do + 5.times do + post '/auth/client-select', params: valid_params, headers: headers + expect(response).not_to have_http_status(:too_many_requests) + end + end + + it 'throttles requests that exceed the per-IP limit' do + 5.times { post '/auth/client-select', params: valid_params, headers: headers } + post '/auth/client-select', params: valid_params, headers: headers + expect(response).to have_http_status(:too_many_requests) + json = JSON.parse(response.body) + expect(json["error"]).to match(/Rate limit exceeded/) + end + + it 'returns a Retry-After header when throttled' do + 5.times { post '/auth/client-select', params: valid_params, headers: headers } + post '/auth/client-select', params: valid_params, headers: headers + expect(response.headers["Retry-After"]).to be_present + end + + it 'throttles requests independently per IP (different IPs are not affected by each other)' do + 5.times { post '/auth/client-select', params: valid_params, headers: headers } + + other_headers = headers.merge("REMOTE_ADDR" => "9.8.7.6") + post '/auth/client-select', params: valid_params, headers: other_headers + expect(response).not_to have_http_status(:too_many_requests) + end + end + + describe 'POST /auth/callback' do + let(:headers) { { "CONTENT_TYPE" => "application/json", "REMOTE_ADDR" => "1.2.3.4" } } + + before do + allow(OidcRequest).to receive(:consume_recent_by_state!).and_raise(ActiveRecord::RecordNotFound) + end + + it 'allows requests within the per-IP limit' do + 10.times do + post '/auth/callback', params: { state: "s", code: "c" }.to_json, headers: headers + expect(response).not_to have_http_status(:too_many_requests) + end + end + + it 'throttles requests that exceed the per-IP limit' do + 10.times { post '/auth/callback', params: { state: "s", code: "c" }.to_json, headers: headers } + post '/auth/callback', params: { state: "s", code: "c" }.to_json, headers: headers + expect(response).to have_http_status(:too_many_requests) + json = JSON.parse(response.body) + expect(json["error"]).to match(/Rate limit exceeded/) + end + + it 'returns a Retry-After header when throttled' do + 10.times { post '/auth/callback', params: { state: "s", code: "c" }.to_json, headers: headers } + post '/auth/callback', params: { state: "s", code: "c" }.to_json, headers: headers + expect(response.headers["Retry-After"]).to be_present + end + end + end +end \ No newline at end of file diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 10e04b446..e793d2f8b 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -713,6 +713,200 @@ paths: responses: '204': description: successful + "/grades/{assignment_id}/view_all_scores": + get: + summary: Retrieve all review scores for an assignment + tags: + - Grades + security: + - bearer_auth: [] + parameters: + - name: assignment_id + in: path + description: ID of the assignment + required: true + schema: + type: integer + - name: participant_id + in: query + required: false + description: ID of the participant + schema: + type: integer + - name: team_id + in: query + required: false + description: ID of the team + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: Returns team scores when team_id provided + '403': + description: Forbidden - Student cannot access + '401': + description: Unauthorized + "/grades/{assignment_id}/view_our_scores": + get: + summary: Retrieve team scores for the requesting student + tags: + - Grades + security: + - bearer_auth: [] + parameters: + - name: assignment_id + in: path + description: ID of the assignment + required: true + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: Returns team scores + '403': + description: Assignment Participant not found + '401': + description: Unauthorized + "/grades/{assignment_id}/view_my_scores": + get: + summary: Retrieve individual participant scores + tags: + - Grades + security: + - bearer_auth: [] + parameters: + - name: assignment_id + in: path + description: ID of the assignment + required: true + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: Returns participant scores + '403': + description: Participant not found + '401': + description: Unauthorized + "/grades/{participant_id}/edit": + get: + summary: Get grade assignment interface data + tags: + - Grades + security: + - bearer_auth: [] + parameters: + - name: participant_id + in: path + description: ID of the participant + required: true + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: Returns participant, assignment, items, and scores + '404': + description: Participant not found + '403': + description: Forbidden - Student cannot access + '401': + description: Unauthorized + "/grades/{participant_id}/assign_grade": + patch: + summary: Assign grades and comment to team + tags: + - Grades + security: + - bearer_auth: [] + parameters: + - name: participant_id + in: path + description: ID of the participant + required: true + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: Team grade and comment assigned successfully + '422': + description: Failed to assign team grade or comment + '404': + description: Participant not found + '403': + description: Forbidden - Student cannot access + '401': + description: Unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + grade_for_submission: + type: number + description: Grade for the submission + comment_for_submission: + type: string + description: Comment for the submission + "/grades/{participant_id}/instructor_review": + get: + summary: Begin or continue grading a submission + tags: + - Grades + security: + - bearer_auth: [] + parameters: + - name: participant_id + in: path + description: ID of the participant + required: true + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: Returns mapping and redirect information for existing review + '404': + description: Participant not found + '403': + description: Forbidden - Student cannot access + '401': + description: Unauthorized "/institutions": get: summary: list institutions @@ -808,6 +1002,12 @@ paths: summary: list invitations tags: - Invitations + parameters: + - name: Authorization + in: header + required: true + schema: + type: string responses: '200': description: Success @@ -815,12 +1015,18 @@ paths: summary: create invitation tags: - Invitations - parameters: [] + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string responses: '201': description: Create successful - '422': - description: Invalid request + '404': + description: Participant not found requestBody: content: application/json: @@ -829,24 +1035,24 @@ paths: properties: assignment_id: type: integer - from_id: - type: integer - to_id: - type: integer - reply_status: + username: type: string required: - assignment_id - - from_id - - to_id + - username "/invitations/{id}": parameters: - name: id in: path - description: id of the invitation required: true schema: type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string get: summary: show invitation tags: @@ -854,8 +1060,8 @@ paths: responses: '200': description: Show invitation - '404': - description: Not found + '403': + description: Cannot see other's invitations patch: summary: update invitation tags: @@ -863,11 +1069,11 @@ paths: parameters: [] responses: '200': - description: Update successful + description: Retraction successful + '403': + description: Cannot retract other's invitations '422': description: Invalid request - '404': - description: Not found requestBody: content: application/json: @@ -876,39 +1082,87 @@ paths: properties: reply_status: type: string - required: [] delete: summary: Delete invitation tags: - Invitations + parameters: + - name: Authorization + in: header + required: true + schema: + type: string responses: - '204': + '403': + description: Student cannot delete invitations + '200': description: Delete successful - '404': - description: Not found - "/invitations/user/{user_id}/assignment/{assignment_id}": + "/invitations/sent_by/team/{team_id}": parameters: - - name: user_id + - name: team_id in: path - description: id of user required: true schema: type: integer - - name: assignment_id + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + get: + summary: Show all invitations sent by team + tags: + - Invitations + responses: + '200': + description: OK + '403': + description: Not allowed + "/invitations/sent_by/participant/{participant_id}": + parameters: + - name: participant_id in: path - description: id of assignment required: true schema: type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string get: - summary: Show all invitation for the given user and assignment + summary: Show all invitations sent by participant tags: - Invitations responses: '200': - description: Show all invitations for the user for an assignment - '404': - description: Not found + description: OK + '403': + description: Not allowed + "/invitations/sent_to/{participant_id}": + parameters: + - name: participant_id + in: path + required: true + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + get: + summary: Show all invitations sent to participant + tags: + - Invitations + responses: + '200': + description: OK + '403': + description: Not allowed "/participants/user/{user_id}": get: summary: Retrieve participants for a specific user @@ -1052,12 +1306,133 @@ paths: properties: user_id: type: integer - description: ID of the user + description: ID of the user + assignment_id: + type: integer + description: ID of the assignment + required: + - user_id + - assignment_id + "/project_topics": + get: + summary: Get project topics + parameters: + - name: assignment_id + in: query + required: true + schema: + type: integer + - name: topic_identifier + in: query + required: false + schema: + type: string + tags: + - ProjectTopic + responses: + '200': + description: successful + delete: + summary: Delete project topics + parameters: + - name: assignment_id + in: query + description: Assignment ID + schema: + type: integer + - name: topic_ids + in: query + items: + type: string + description: Topic Identifiers to delete + required: false + schema: + type: array + - name: Authorization + in: header + tags: + - ProjectTopic + responses: + '422': + description: when assignment_id parameter is missing + '204': + description: when assignment_id parameter is present but topic_ids parameter + is missing + post: + summary: create a new topic in the sheet + tags: + - ProjectTopic + parameters: [] + responses: + '201': + description: when the assignment is not a microtask + '422': + description: when the assignment does not exist + requestBody: + content: + application/json: + schema: + type: object + properties: + topic_identifier: + type: string + topic_name: + type: string + max_choosers: + type: integer + category: + type: string + assignment_id: + type: integer + micropayment: + type: integer + required: + - topic_identifier + - topic_name + - max_choosers + - category + - assignment_id + - micropayment + "/project_topics/{id}": + parameters: + - name: id + in: path + description: ID of the project topic + required: true + schema: + type: integer + put: + summary: update a topic in the sheet + tags: + - ProjectTopic + parameters: [] + responses: + '200': + description: when the assignment is not a microtask + '422': + description: when the assignment does not exist + requestBody: + content: + application/json: + schema: + type: object + properties: + topic_identifier: + type: string + topic_name: + type: string + max_choosers: + type: integer + category: + type: string assignment_id: type: integer - description: ID of the assignment + micropayment: + type: integer required: - - user_id + - topic_identifier + - topic_name + - category - assignment_id "/questionnaires": get: @@ -1201,6 +1576,27 @@ paths: required: true schema: type: integer + - name: questionnaire1 + in: body + schema: + type: object + properties: + name: + type: string + questionnaire_type: + type: string + private: + type: boolean + min_question_score: + type: integer + max_question_score: + type: integer + instructor_id: + type: integer + required: + - name + - questionnaire_type + - instructor_id post: summary: copy questionnaire tags: @@ -1508,16 +1904,134 @@ paths: description: participant not found '401': description: unauthorized request has error response + "/student_teams/view": + get: + summary: View student team + tags: + - Student Teams + parameters: + - name: student_id + in: query + schema: + type: integer + - name: Authorization + in: header + required: true + schema: + type: string + responses: + '200': + description: Student not on any team + '403': + description: TA cannot access + "/student_teams": + post: + summary: Create team + tags: + - Student Teams + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + responses: + '200': + description: Create successful + '422': + description: Duplicate name + '403': + description: Unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + team: + type: object + properties: + name: + type: string + required: + - name + assignment_id: + type: integer + student_id: + type: integer + required: + - team + - assignment_id + - student_id + "/student_teams/update": + put: + summary: Update team name + tags: + - Student Teams + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + - name: student_id + in: query + schema: + type: integer + responses: + '200': + description: Update successful + '422': + description: Duplicate name + '403': + description: Unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + team: + type: object + properties: + name: + type: string + required: + - team + "/student_teams/leave": + put: + summary: Leave team + tags: + - Student Teams + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + - name: student_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Leave successful + '403': + description: TA cannot leave "/submitted_content": get: summary: list all submission records tags: - SubmittedContent + responses: + '200': + description: successful post: summary: create a submission record tags: - SubmittedContent - parameters: [ ] + parameters: [] responses: '201': description: created @@ -1566,106 +2080,8 @@ paths: description: successful '404': description: not found - "/submitted_content/submit_hyperlink": - post: - summary: submit hyperlink (swagger) - tags: - - SubmittedContent - parameters: - - name: Authorization - in: header - schema: - type: string - - name: id - in: query - schema: - type: string - required: true - - name: submit_link - in: query - schema: - type: string - required: true - topic_identifier: - type: integer - topic_name: - type: string - max_choosers: - type: integer - category: - type: string - assignment_id: - type: integer - micropayment: - type: integer - required: - - topic_identifier - - topic_name - - max_choosers - - category - - assignment_id - - micropayment - "/project_topics/{id}": - parameters: - - name: id - in: path - description: id of the sign up topic - required: true - schema: - type: integer - put: - summary: update a new topic in the sheet - tags: - - ProjectTopic - parameters: [ ] - responses: - '200': - description: successful - "/project_topics": - get: - summary: Get project topics - parameters: - - name: assignment_id - in: query - description: Assignment ID - required: true - schema: - type: integer - - name: topic_ids - in: query - description: Topic Identifier - required: false - schema: - type: string - tags: - - ProjectTopic - responses: - '200': - description: successful - delete: - summary: Delete project topics - parameters: - - name: assignment_id - in: query - description: Assignment ID - required: true - schema: - type: integer - - name: topic_ids - in: query - items: - type: string - description: Topic Identifiers to delete - required: false - schema: - type: array - tags: - - ProjectTopic - responses: - '200': - description: successful "/submitted_content/remove_hyperlink": - post: + delete: summary: remove hyperlink (swagger) tags: - SubmittedContent @@ -1716,11 +2132,8 @@ paths: content: multipart/form-data: schema: - type: object - properties: - uploaded_file: - type: string - format: binary + type: string + format: binary required: true "/submitted_content/folder_action": post: @@ -1760,7 +2173,6 @@ paths: required: true schema: type: string - description: Folder path (use "/" for root) - name: download in: query required: true @@ -1776,8 +2188,6 @@ paths: description: cannot send whole folder '404': description: file does not exist - '200': - description: file downloaded "/submitted_content/list_files": get: summary: list files and hyperlinks @@ -1789,13 +2199,10 @@ paths: required: true schema: type: string - - name: folder + - name: folder[name] in: query schema: - type: object - properties: - name: - type: string + type: string responses: '200': description: directory listed @@ -1910,6 +2317,7 @@ paths: summary: Logs in a user tags: - Authentication + security: [] parameters: [] responses: '200': @@ -1929,6 +2337,187 @@ paths: required: - user_name - password + "/auth/providers": + get: + summary: List available OIDC providers + tags: + - OIDC Authentication + security: [] + description: Returns the list of configured OIDC identity providers that the + front end can offer to users. + responses: + '200': + description: list of providers + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + example: google-ncsu + name: + type: string + example: Google NCSU + required: + - id + - name + "/auth/client-select": + post: + summary: Initiate OIDC login for a chosen provider + tags: + - OIDC Authentication + security: [] + description: | + Accepts a provider key and username, performs OIDC discovery, generates PKCE and state + parameters, persists an OidcRequest for later verification, and returns the provider's + authorization URL that the front end should redirect the user to. + Username is required because emails are not unique in Expertiza. + parameters: [] + responses: + '200': + description: authorization redirect URI + content: + application/json: + schema: + type: object + properties: + redirect_uri: + type: string + example: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&scope=openid+email+profile&state=...&nonce=... + required: + - redirect_uri + '400': + description: missing required parameters + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'param is missing or the value is empty: username' + required: + - error + '404': + description: unknown OIDC provider + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Unknown OIDC provider: nonexistent' + required: + - error + '502': + description: provider discovery failed + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Provider communication failed: ...' + required: + - error + requestBody: + content: + application/json: + schema: + type: object + properties: + provider: + type: string + example: google-ncsu + description: Key identifying the OIDC provider + username: + type: string + example: jdoe + description: Expertiza username for account matching + required: + - provider + - username + "/auth/callback": + post: + summary: Exchange an OIDC authorization code for a session token + tags: + - OIDC Authentication + security: [] + description: | + Called by the front end after the user is redirected back from the identity provider. + Exchanges the authorization code for tokens, verifies the ID token, and returns + a local JWT session token if the user's username and email match an existing account. + Returns a generic error for all failure modes to avoid information leakage. + parameters: [] + responses: + '200': + description: authenticated user with session token + content: + application/json: + schema: + type: object + properties: + token: + type: string + description: JWT session token + required: + - token + '401': + description: authentication failed + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Authentication failed + required: + - error + '400': + description: missing required parameters + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'param is missing or the value is empty: state' + required: + - error + '502': + description: provider communication failed + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Provider communication failed: ...' + required: + - error + requestBody: + content: + application/json: + schema: + type: object + properties: + state: + type: string + description: The state parameter returned by the identity provider + code: + type: string + description: The authorization code returned by the identity provider + required: + - state + - code servers: - url: http://{defaultHost} variables: