3 Commits

Author SHA1 Message Date
Dan Milne
e631f606e7 Better error messages
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-03 12:29:27 +11:00
Dan Milne
f4a697ae9b More OpenID Conformance test fixes - work with POST, correct auth code character set, correct no-store cache headers 2026-01-03 12:28:43 +11:00
Dan Milne
16e34ffaf0 Updates for oidc conformance 2026-01-03 10:11:10 +11:00
5 changed files with 128 additions and 16 deletions

View File

@@ -52,12 +52,24 @@ module Authentication
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)
domain = extract_root_domain(request.host) domain = extract_root_domain(request.host)
cookie_options = { # Set cookie options based on environment
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
# Development: Use SameSite=Lax since HTTPS might not be available
cookie_options = if Rails.env.production?
{
value: session.id,
httponly: true,
same_site: :none, # Allow cross-site cookies for OIDC testing
secure: true # Required for SameSite=None
}
else
{
value: session.id, value: session.id,
httponly: true, httponly: true,
same_site: :lax, same_site: :lax,
secure: Rails.env.production? secure: false
} }
end
# Set domain for cross-subdomain authentication if we can extract it # Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present? cookie_options[:domain] = domain if domain.present?

View File

@@ -1,7 +1,8 @@
class OidcController < ApplicationController class OidcController < ApplicationController
# Discovery and JWKS endpoints are public # Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout] # authorize is also unauthenticated to handle prompt=none and prompt=login specially
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout] 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 limiting to prevent brute force and abuse
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
@@ -43,7 +44,8 @@ class OidcController < ApplicationController
], ],
code_challenge_methods_supported: ["plain", "S256"], code_challenge_methods_supported: ["plain", "S256"],
backchannel_logout_supported: true, backchannel_logout_supported: true,
backchannel_logout_session_supported: true backchannel_logout_session_supported: true,
request_parameter_supported: false
} }
render json: config render json: config
@@ -119,6 +121,18 @@ class OidcController < ApplicationController
# per OAuth2 RFC 6749 Section 4.1.2.1 # 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) # Validate response_type (now we can safely redirect with error)
unless response_type == "code" unless response_type == "code"
Rails.logger.error "OAuth: Invalid response_type: #{response_type}" Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
@@ -162,7 +176,17 @@ class OidcController < ApplicationController
# Check if user is authenticated # Check if user is authenticated
unless 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] = { session[:oauth_params] = {
client_id: client_id, client_id: client_id,
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
@@ -172,10 +196,57 @@ class OidcController < ApplicationController
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method 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" redirect_to signin_path, alert: "Please sign in to continue"
return return
end 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!
# Clear the session cookie so the user is truly logged out
cookies.delete(:session_id)
# 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 # Get the authenticated user
user = Current.session.user user = Current.session.user
@@ -468,6 +539,10 @@ class OidcController < ApplicationController
scopes: auth_code.scope scopes: auth_code.scope
) )
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return tokens # Return tokens
render json: { render json: {
access_token: access_token_record.plaintext_token, # Opaque token access_token: access_token_record.plaintext_token, # Opaque token
@@ -597,6 +672,10 @@ class OidcController < ApplicationController
scopes: refresh_token_record.scope scopes: refresh_token_record.scope
) )
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return new tokens # Return new tokens
render json: { render json: {
access_token: new_access_token.plaintext_token, # Opaque token access_token: new_access_token.plaintext_token, # Opaque token
@@ -695,6 +774,10 @@ class OidcController < ApplicationController
application = access_token.application application = access_token.application
claims.merge!(application.custom_claims_for_user(user)) claims.merge!(application.custom_claims_for_user(user))
# Security: Don't cache user data responses
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
render json: claims render json: claims
end end
@@ -839,12 +922,12 @@ class OidcController < ApplicationController
} }
end end
# Validate code verifier format (base64url-encoded, 43-128 characters) # Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/) unless code_verifier.match?(/\A[A-Za-z0-9\.\-_~]{43,128}\z/)
return { return {
valid: false, valid: false,
error: "invalid_request", error: "invalid_request",
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding", error_description: "Invalid code_verifier format. Must be 43-128 characters [A-Z/a-z/0-9/-/./_/~]",
status: :bad_request status: :bad_request
} }
end end

View File

@@ -14,6 +14,20 @@ class SessionsController < ApplicationController
return return
end 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| respond_to do |format|
format.html # render HTML login page format.html # render HTML login page
format.json { render json: {error: "Authentication required"}, status: :unauthorized } format.json { render json: {error: "Authentication required"}, status: :unauthorized }
@@ -73,7 +87,10 @@ class SessionsController < ApplicationController
# Sign in successful (password only) # Sign in successful (password only)
start_new_session_for user, acr: "1" start_new_session_for user, acr: "1"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
# Use status: :see_other to ensure browser makes a GET request
# This prevents Turbo from converting it to a TURBO_STREAM request
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other
end end
def verify_totp def verify_totp

View File

@@ -12,7 +12,7 @@
autofocus: true, autofocus: true,
autocomplete: "username", autocomplete: "username",
placeholder: "your@email.com", placeholder: "your@email.com",
value: params[:email_address], value: @login_hint || params[:email_address],
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" }, 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" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div> </div>

View File

@@ -26,7 +26,7 @@ Rails.application.routes.draw do
# OIDC (OpenID Connect) routes # OIDC (OpenID Connect) routes
get "/.well-known/openid-configuration", to: "oidc#discovery" get "/.well-known/openid-configuration", to: "oidc#discovery"
get "/.well-known/jwks.json", to: "oidc#jwks" 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/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token" post "/oauth/token", to: "oidc#token"
post "/oauth/revoke", to: "oidc#revoke" post "/oauth/revoke", to: "oidc#revoke"