From 16e34ffaf0106a8eb765b5ee53489cd498f864a2 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Sat, 3 Jan 2026 10:11:10 +1100 Subject: [PATCH] Updates for oidc conformance --- app/controllers/oidc_controller.rb | 76 ++++++++++++++++++++++++-- app/controllers/sessions_controller.rb | 14 +++++ app/views/sessions/new.html.erb | 2 +- config/routes.rb | 2 +- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 5198472..95b8714 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -1,7 +1,8 @@ class OidcController < ApplicationController # Discovery and JWKS endpoints are public - allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout] - skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout] + # authorize is also unauthenticated to handle prompt=none and prompt=login specially + allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize] + skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent] # Rate limiting to prevent brute force and abuse rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { @@ -43,7 +44,8 @@ class OidcController < ApplicationController ], code_challenge_methods_supported: ["plain", "S256"], backchannel_logout_supported: true, - backchannel_logout_session_supported: true + backchannel_logout_session_supported: true, + request_parameter_supported: false } render json: config @@ -119,6 +121,18 @@ class OidcController < ApplicationController # per OAuth2 RFC 6749 Section 4.1.2.1 # ============================================================================ + # Reject request objects (JWT-encoded authorization parameters) + # Per OIDC Core §3.1.2.6: If request parameter is present and not supported, + # return request_not_supported error + if params[:request].present? || params[:request_uri].present? + Rails.logger.error "OAuth: Request object not supported" + error_uri = "#{redirect_uri}?error=request_not_supported" + error_uri += "&error_description=#{CGI.escape("Request objects are not supported")}" + error_uri += "&state=#{CGI.escape(state)}" if state.present? + redirect_to error_uri, allow_other_host: true + return + end + # Validate response_type (now we can safely redirect with error) unless response_type == "code" Rails.logger.error "OAuth: Invalid response_type: #{response_type}" @@ -162,7 +176,17 @@ class OidcController < ApplicationController # Check if user is authenticated unless authenticated? - # Store OAuth parameters in session and redirect to sign in + # Handle prompt=none - no UI allowed, return error immediately + # Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated, + # return login_required error without showing any UI + if params[:prompt] == "none" + error_uri = "#{redirect_uri}?error=login_required" + error_uri += "&state=#{CGI.escape(state)}" if state.present? + redirect_to error_uri, allow_other_host: true + return + end + + # Normal flow: store OAuth parameters and redirect to sign in session[:oauth_params] = { client_id: client_id, redirect_uri: redirect_uri, @@ -172,10 +196,54 @@ class OidcController < ApplicationController code_challenge: code_challenge, code_challenge_method: code_challenge_method } + # Store the current URL (with all OAuth params) for redirect after authentication + session[:return_to_after_authenticating] = request.url redirect_to signin_path, alert: "Please sign in to continue" return end + # Handle prompt=login - force re-authentication + # Per OIDC Core spec §3.1.2.1: If prompt=login, the Authorization Server MUST prompt + # the End-User for reauthentication, even if the End-User is currently authenticated + if params[:prompt] == "login" + # Destroy current session to force re-authentication + # This creates a fresh authentication event with a new auth_time + Current.session&.destroy! + + # Store the current URL (which contains all OAuth params) for redirect after login + # Remove prompt=login to prevent infinite re-auth loop + return_url = request.url.sub(/&prompt=login(?=&|$)|\?prompt=login&?/, '\1') + # Fix any resulting URL issues (like ?& or & at end) + return_url = return_url.gsub("?&", "?").gsub(/[?&]$/, "") + session[:return_to_after_authenticating] = return_url + + redirect_to signin_path, alert: "Please sign in to continue" + return + end + + # Handle max_age - require re-authentication if session is too old + # Per OIDC Core spec §3.1.2.1: If max_age is provided and the auth time is older, + # the Authorization Server MUST prompt for reauthentication + if params[:max_age].present? + max_age_seconds = params[:max_age].to_i + # Calculate session age + session_age_seconds = Time.current.to_i - Current.session.created_at.to_i + + if session_age_seconds > max_age_seconds + # Session is too old - require re-authentication + # Store return URL in session (creates new session cookie) + + # Destroy session and clear cookie to force fresh login + Current.session&.destroy! + cookies.delete(:session_id) + + session[:return_to_after_authenticating] = request.url + + redirect_to signin_path, alert: "Please sign in to continue" + return + end + end + # Get the authenticated user user = Current.session.user diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4d80283..f3fd5ca 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -14,6 +14,20 @@ class SessionsController < ApplicationController return end + # Extract login_hint from the return URL for pre-filling the email field (OIDC spec) + @login_hint = nil + if session[:return_to_after_authenticating].present? + begin + uri = URI.parse(session[:return_to_after_authenticating]) + if uri.query.present? + query_params = CGI.parse(uri.query) + @login_hint = query_params["login_hint"]&.first + end + rescue URI::InvalidURIError + # Ignore parsing errors + end + end + respond_to do |format| format.html # render HTML login page format.json { render json: {error: "Authentication required"}, status: :unauthorized } diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index a5b27d1..f345ce4 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -12,7 +12,7 @@ autofocus: true, autocomplete: "username", placeholder: "your@email.com", - value: params[:email_address], + value: @login_hint || params[:email_address], data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" }, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> diff --git a/config/routes.rb b/config/routes.rb index 963a6c6..27ecc3c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,7 +26,7 @@ Rails.application.routes.draw do # OIDC (OpenID Connect) routes get "/.well-known/openid-configuration", to: "oidc#discovery" get "/.well-known/jwks.json", to: "oidc#jwks" - get "/oauth/authorize", to: "oidc#authorize" + match "/oauth/authorize", to: "oidc#authorize", via: [:get, :post] post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent post "/oauth/token", to: "oidc#token" post "/oauth/revoke", to: "oidc#revoke"