Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
f4621e7
add oidc config, controller, and models
johnmweisz Apr 7, 2026
bf658f6
seed team users that match google login for dev convenience
johnmweisz Apr 7, 2026
0c3f19e
Remove callback_redirect method from oidc_login_controller
johnmweisz Apr 7, 2026
e386c6d
remove testing route
johnmweisz Apr 7, 2026
52a89d9
remove discovery option, assume always true to match OIDC spec
johnmweisz Apr 10, 2026
430cef4
Merge pull request #47 from johnmweisz/jweisz/discovery-always-true
johnmweisz Apr 10, 2026
5a42d96
Initial plan
Copilot Apr 10, 2026
01b969a
Add Swagger/OpenAPI documentation for OIDC provider endpoints
Copilot Apr 10, 2026
1f1cd0b
normalize login response
johnmweisz Apr 10, 2026
c4d418b
move jwt logic into user
johnmweisz Apr 10, 2026
0efcd03
add comment
johnmweisz Apr 10, 2026
a4d499b
add user jwt tests, update login tests
johnmweisz Apr 11, 2026
50cb820
add user jwt tests, update login tests
johnmweisz Apr 11, 2026
0de0eb0
Merge pull request #51 from johnmweisz/jweisz/normalize-login-response
johnmweisz Apr 11, 2026
c5b87b6
Merge branch '2618-oidc-login' into copilot/add-swagger-documentation…
johnmweisz Apr 11, 2026
3008e3d
Fix callback response schema, add 401 response, scope swagger changes…
Copilot Apr 11, 2026
c82cc5b
regen swagger file
johnmweisz Apr 11, 2026
e97549f
Merge pull request #50 from johnmweisz/copilot/add-swagger-documentat…
johnmweisz Apr 11, 2026
e986483
move logic into oidc_request model, add tests
johnmweisz Apr 11, 2026
e963982
refactor and simplify
johnmweisz Apr 11, 2026
f6bb663
add clarifying details to config
johnmweisz Apr 11, 2026
83d9dda
update tests to reflect simplified config
johnmweisz Apr 11, 2026
e16eb99
Update app/controllers/oidc_login_controller.rb
johnmweisz Apr 11, 2026
69a24f7
scopes are strings
johnmweisz Apr 11, 2026
3888048
fix validate! hash-during-iteration bug and add nil guards for YAML p…
Copilot Apr 11, 2026
792773a
simplify
johnmweisz Apr 11, 2026
c3bc93e
add 404 client select
johnmweisz Apr 11, 2026
24cdb6e
Update app/models/oidc_config.rb
johnmweisz Apr 11, 2026
f2c9b29
consolidate 404
johnmweisz Apr 11, 2026
1122534
Merge branch 'jweisz/provider-config' of https://github.com/johnmweis…
johnmweisz Apr 11, 2026
fe2b917
Update app/models/oidc_request.rb
johnmweisz Apr 11, 2026
82ae00f
refactor for consistency
johnmweisz Apr 13, 2026
9cc6d73
revert private method
johnmweisz Apr 13, 2026
e3b1151
update tests with discovery rescue
johnmweisz Apr 13, 2026
1130404
Merge pull request #52 from johnmweisz/jweisz/provider-config
johnmweisz Apr 14, 2026
1fa8678
remove dev seed
johnmweisz Apr 14, 2026
747f8b3
propogate oidc_request rename
johnmweisz Apr 16, 2026
ee0b8cd
add username requirement to oidc
johnmweisz Apr 16, 2026
9f96ecc
update tests and swagger
johnmweisz Apr 16, 2026
6da7443
refine tests, fix existing login swagger
johnmweisz Apr 16, 2026
a55c419
Update client_select method comments
johnmweisz Apr 16, 2026
f157940
explicit case insensitive lookup
johnmweisz Apr 17, 2026
0108279
Merge branch 'jweisz/oidc-request-with-username' of https://github.co…
johnmweisz Apr 17, 2026
5ea7dd8
Consolidated validity window.
JaredM2028 Apr 16, 2026
3a508a5
Stale logins are deleted if attempted by user.
JaredM2028 Apr 16, 2026
33f33f0
Probabilistic cleanup for new OIDC login request.
JaredM2028 Apr 18, 2026
a0b9791
Fixed existing tests that previously checked if stale OIDC requests r…
JaredM2028 Apr 18, 2026
e697897
raising in the if-stale check rolls back the destroy, so the row in t…
JaredM2028 Apr 18, 2026
75d109c
Added cleanup job from other PR because having the cleanup as a job m…
JaredM2028 Apr 18, 2026
c3efd70
Cleaned up OIDC tests and added new ones for cleanup.
JaredM2028 Apr 18, 2026
21284d5
Quick format fix.
JaredM2028 Apr 18, 2026
b85bf2a
Refactored design to make :recent and :stale private.
JaredM2028 Apr 19, 2026
f2f424f
Internalized window, which means we can't override it anymore in the …
JaredM2028 Apr 19, 2026
4ab2e42
Improved test for probabilistic cleanup.
JaredM2028 Apr 19, 2026
0484599
Merge pull request #58 from johnmweisz/cleanup_stale_auth_requests_v2
johnmweisz Apr 19, 2026
29c04a7
Merge branch '2618-oidc-login' into jweisz/oidc-request-with-username
johnmweisz Apr 19, 2026
effeff8
resolve conflicts, minor refactor
johnmweisz Apr 19, 2026
9ad2ec7
add raise note
johnmweisz Apr 19, 2026
0b05104
Update app/models/oidc_request.rb
johnmweisz Apr 19, 2026
03b2447
Improved existing OIDC test.
JaredM2028 Apr 19, 2026
ccae37a
test(oidc_request): add delete_stale, stale?, and InvalidToken coverage
JaredM2028 Apr 19, 2026
4dac121
cover non-Hash YAML guards and mixed delimiter scopes
JaredM2028 Apr 19, 2026
72f27ba
Merge pull request #57 from johnmweisz/jweisz/oidc-request-with-username
johnmweisz Apr 20, 2026
6efc3f8
single quote routes
johnmweisz Apr 20, 2026
8ce34f3
Merge pull request #60 from johnmweisz/jweisz/single-quote-route
johnmweisz Apr 20, 2026
e6c1aa1
Merge branch '2618-oidc-login' into complete_backend_tests
Copilot Apr 21, 2026
04895da
Deduplicate stale cleanup specs after merge
Copilot Apr 21, 2026
6af15a6
Use create_request helper in merged stale tests
Copilot Apr 21, 2026
00dcb5d
Test cleanup after username PR.
JaredM2028 Apr 21, 2026
dba74d0
Addressed copilots feedback.
JaredM2028 Apr 21, 2026
5aa5cac
enforce param requirements also in db
johnmweisz Apr 21, 2026
710140f
require email verified from idp
johnmweisz Apr 21, 2026
5163c3f
Merge pull request #62 from johnmweisz/jweisz/refine-schema
johnmweisz Apr 21, 2026
23c7781
normalize user lookup
johnmweisz Apr 21, 2026
a646627
Merge branch '2618-oidc-login' into jweisz/normalize-user-lookup
johnmweisz Apr 21, 2026
af73f5c
update tests
johnmweisz Apr 22, 2026
c1b80cc
Merge branch '2618-oidc-login' into jweisz/require-email-verified
johnmweisz Apr 22, 2026
5b184dc
update factory
johnmweisz Apr 22, 2026
5cb7147
Merge pull request #66 from johnmweisz/jweisz/update-factory
johnmweisz Apr 22, 2026
f48eb15
Made yaml test more readable.
JaredM2028 Apr 22, 2026
bc127a1
Merge pull request #63 from johnmweisz/jweisz/require-email-verified
johnmweisz Apr 22, 2026
0690799
Merge pull request #65 from johnmweisz/jweisz/normalize-user-lookup
johnmweisz Apr 22, 2026
8d7bd76
add comments and resolve nits
johnmweisz Apr 22, 2026
4b0c1e6
Merge pull request #69 from johnmweisz/jweisz/resolve-nits
johnmweisz Apr 22, 2026
b4690ec
Merge branch '2618-oidc-login' into complete_backend_tests
johnmweisz Apr 22, 2026
a2bc177
Merge pull request #61 from johnmweisz/complete_backend_tests
johnmweisz Apr 22, 2026
a835415
Added Ruby Gem
JaredM2028 Apr 23, 2026
69e09df
updated gemfile.lock
JaredM2028 Apr 23, 2026
60b014e
Rack attack rules
JaredM2028 Apr 23, 2026
d2840f2
Test coverage
JaredM2028 Apr 23, 2026
4157b68
Address PR comments
JaredM2028 Apr 23, 2026
8254bd2
provider fail on startup when prod
johnmweisz Apr 23, 2026
0f5395c
Trimmed IP+username limiter since its benefit was marginal.
JaredM2028 Apr 24, 2026
8aee801
Use MemoryStore for Rack::Attack cache in test and development enviro…
JaredM2028 Apr 24, 2026
f6ff139
Merge pull request #70 from johnmweisz/oidc_rate_limiting
johnmweisz Apr 24, 2026
306f67e
refine handling
johnmweisz Apr 24, 2026
9bc24c3
Merge pull request #71 from johnmweisz/jweisz/stronger-provider-valid…
johnmweisz Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
50 changes: 50 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 1 addition & 6 deletions app/controllers/authentication_controller.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
# app/controllers/authentication_controller.rb
require 'json_web_token'

class AuthenticationController < ApplicationController
skip_before_action :authenticate_request!

# POST /login
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
Expand Down
70 changes: 70 additions & 0 deletions app/controllers/oidc_login_controller.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions app/jobs/cleanup_stale_oidc_requests_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class CleanupStaleOidcRequestsJob < ApplicationJob
queue_as :default

def perform
OidcRequest.delete_stale
end
end
90 changes: 90 additions & 0 deletions app/models/oidc_config.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading