OIDC: IPv6 loopback DCR, dedup error redirects + scope labels
Security-review follow-ups on the OIDC flows. IPv6 loopback redirect_uri in dynamic client registration (RFC 8252): - valid_redirect_uri? compared URI#host, which returns the bracketed "[::1]" for IPv6 literals, so http://[::1]:PORT/... was always rejected. Compare #hostname (unbracketed) instead, unblocking IPv6-only native clients. Extract redirect_authorize_error (removes 12 copies of the boilerplate): - The authorize/consent flows built the error redirect by hand ~a dozen times (error_uri = "...?error=..."; += "&error_description=#{CGI.escape ...}"; += state; redirect_to). One helper now composes the query, and — unlike every copy — appends "&error=..." when the redirect_uri already has a query string instead of a malformed second "?". Extract token-endpoint helpers (removes ~80 duplicated lines): - authenticate_token_client: the identical client-auth preamble shared by the authorization-code, refresh, and device-code grants. - render_token_triple: the access + refresh + id_token mint and RFC 6749 §5.1 response shared by the authorization-code and device-code grants. Single source of truth for scope descriptions: - New OidcHelper#scope_description + shared shared/_scope_list partial, used by both the browser consent screen and the device authorization screen; drops the scope_labels hash and the hard-coded per-scope blocks that would have drifted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
79a7524fda
commit
f65f8da2e7
@@ -203,30 +203,21 @@ class OidcController < ApplicationController
|
|||||||
# return request_not_supported error
|
# return request_not_supported error
|
||||||
if params[:request].present? || params[:request_uri].present?
|
if params[:request].present? || params[:request_uri].present?
|
||||||
Rails.logger.error "OAuth: Request object not supported"
|
Rails.logger.error "OAuth: Request object not supported"
|
||||||
error_uri = "#{redirect_uri}?error=request_not_supported"
|
redirect_authorize_error(redirect_uri, "request_not_supported", description: "Request objects are not supported", state: state)
|
||||||
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
|
return
|
||||||
end
|
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}"
|
||||||
error_uri = "#{redirect_uri}?error=unsupported_response_type"
|
redirect_authorize_error(redirect_uri, "unsupported_response_type", description: "Only 'code' response_type is supported", state: state)
|
||||||
error_uri += "&error_description=#{CGI.escape("Only 'code' response_type is supported")}"
|
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# RFC 8707 §2: if a resource indicator is supplied it must be a valid target,
|
# RFC 8707 §2: if a resource indicator is supplied it must be a valid target,
|
||||||
# otherwise the request is rejected with error=invalid_target.
|
# otherwise the request is rejected with error=invalid_target.
|
||||||
if resource.present? && !valid_resource_indicator?(resource)
|
if resource.present? && !valid_resource_indicator?(resource)
|
||||||
error_uri = "#{redirect_uri}?error=invalid_target"
|
redirect_authorize_error(redirect_uri, "invalid_target", description: "resource must be an absolute URI without a fragment", state: state)
|
||||||
error_uri += "&error_description=#{CGI.escape("resource must be an absolute URI without a fragment")}"
|
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -234,20 +225,14 @@ class OidcController < ApplicationController
|
|||||||
if code_challenge.present?
|
if code_challenge.present?
|
||||||
unless code_challenge_method == "S256"
|
unless code_challenge_method == "S256"
|
||||||
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
||||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge_method: only 'S256' is supported", state: state)
|
||||||
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: only 'S256' is supported")}"
|
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
Rails.logger.error "OAuth: Invalid code_challenge format"
|
Rails.logger.error "OAuth: Invalid code_challenge format"
|
||||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", state: state)
|
||||||
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge format: must be 43-128 characters of base64url encoding")}"
|
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -267,10 +252,7 @@ class OidcController < ApplicationController
|
|||||||
# Validate claims parameter format if present
|
# Validate claims parameter format if present
|
||||||
if claims_parameter.present? && parsed_claims.nil?
|
if claims_parameter.present? && parsed_claims.nil?
|
||||||
Rails.logger.error "OAuth: Invalid claims parameter format"
|
Rails.logger.error "OAuth: Invalid claims parameter format"
|
||||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid claims parameter: must be valid JSON", state: state)
|
||||||
error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}"
|
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -279,10 +261,7 @@ class OidcController < ApplicationController
|
|||||||
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
|
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
|
||||||
unless validation_result[:valid]
|
unless validation_result[:valid]
|
||||||
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
|
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
|
||||||
error_uri = "#{redirect_uri}?error=invalid_scope"
|
redirect_authorize_error(redirect_uri, "invalid_scope", description: "Claims parameter requests claims not covered by granted scopes", state: state)
|
||||||
error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}"
|
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -290,9 +269,7 @@ class OidcController < ApplicationController
|
|||||||
# Check if application is active (now we can safely redirect with error)
|
# Check if application is active (now we can safely redirect with error)
|
||||||
unless @application.active?
|
unless @application.active?
|
||||||
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||||
error_uri = "#{redirect_uri}?error=unauthorized_client&error_description=Application+is+not+active"
|
redirect_authorize_error(redirect_uri, "unauthorized_client", description: "Application is not active", state: state)
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -302,9 +279,7 @@ class OidcController < ApplicationController
|
|||||||
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
|
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
|
||||||
# return login_required error without showing any UI
|
# return login_required error without showing any UI
|
||||||
if params[:prompt] == "none"
|
if params[:prompt] == "none"
|
||||||
error_uri = "#{redirect_uri}?error=login_required"
|
redirect_authorize_error(redirect_uri, "login_required", state: state)
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -383,9 +358,7 @@ class OidcController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
unless requested_scopes.include?("openid")
|
unless requested_scopes.include?("openid")
|
||||||
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}"
|
redirect_authorize_error(redirect_uri, "invalid_scope", description: "The 'openid' scope is required", state: state)
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -499,9 +472,7 @@ class OidcController < ApplicationController
|
|||||||
# User denied consent
|
# User denied consent
|
||||||
if params[:deny].present?
|
if params[:deny].present?
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
redirect_authorize_error(oauth_params["redirect_uri"], "access_denied", state: oauth_params["state"])
|
||||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -513,9 +484,7 @@ class OidcController < ApplicationController
|
|||||||
unless application&.active?
|
unless application&.active?
|
||||||
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
redirect_authorize_error(oauth_params["redirect_uri"], "unauthorized_client", description: "Application is not active", state: oauth_params["state"])
|
||||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
|
||||||
redirect_to error_uri, allow_other_host: true
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1382,6 +1351,19 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Redirect back to the client's redirect_uri with an OAuth 2.0 authorization
|
||||||
|
# error (RFC 6749 §4.1.2.1). Extracted because the authorize / consent flows
|
||||||
|
# report errors this same way ~a dozen times. Composes the query safely so a
|
||||||
|
# redirect_uri that already carries a query string gets "&error=..." rather than
|
||||||
|
# a second "?", and CGI-escapes the description and state.
|
||||||
|
def redirect_authorize_error(redirect_uri, error, description: nil, state: nil)
|
||||||
|
query = {error: error}
|
||||||
|
query[:error_description] = description if description
|
||||||
|
query[:state] = state if state.present?
|
||||||
|
separator = redirect_uri.include?("?") ? "&" : "?"
|
||||||
|
redirect_to "#{redirect_uri}#{separator}#{query.to_query}", allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
# Look up @application from client_id. RFC 6749 §4.1.2.1 requires that an
|
# Look up @application from client_id. RFC 6749 §4.1.2.1 requires that an
|
||||||
# invalid client_id be reported on-page, not via redirect.
|
# invalid client_id be reported on-page, not via redirect.
|
||||||
def set_application
|
def set_application
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ class OidcRegistrationController < ApplicationController
|
|||||||
parsed = URI.parse(uri)
|
parsed = URI.parse(uri)
|
||||||
return false unless parsed.is_a?(URI::HTTP) # covers HTTP and HTTPS
|
return false unless parsed.is_a?(URI::HTTP) # covers HTTP and HTTPS
|
||||||
return true if parsed.scheme == "https"
|
return true if parsed.scheme == "https"
|
||||||
%w[localhost 127.0.0.1 ::1].include?(parsed.host)
|
# #hostname (not #host) returns the unbracketed form for IPv6 literals, so the
|
||||||
|
# RFC 8252 IPv6 loopback http://[::1]:PORT/... compares as "::1", not "[::1]".
|
||||||
|
%w[localhost 127.0.0.1 ::1].include?(parsed.hostname)
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module OidcHelper
|
||||||
|
# Single source of truth for the human-readable description of what each OAuth
|
||||||
|
# scope grants. Shown on both the browser consent screen (oidc/consent) and the
|
||||||
|
# device authorization screen (device_authorizations/show) via the shared
|
||||||
|
# shared/_scope_list partial. Unknown scopes fall back to their raw name.
|
||||||
|
def scope_description(scope, user: Current.user)
|
||||||
|
case scope
|
||||||
|
when "openid" then "Verify your identity"
|
||||||
|
when "email" then "Access your email address (#{user&.email_address})"
|
||||||
|
when "profile" then "Access your profile information"
|
||||||
|
when "groups" then "Access your group memberships"
|
||||||
|
when "offline_access" then "Stay signed in (refresh access)"
|
||||||
|
else scope
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -27,17 +27,7 @@
|
|||||||
<% if @scopes.any? %>
|
<% if @scopes.any? %>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This will be able to:</h3>
|
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This will be able to:</h3>
|
||||||
<ul class="space-y-2">
|
<%= render "shared/scope_list", scopes: @scopes %>
|
||||||
<% scope_labels = { "openid" => "Verify your identity", "email" => "Access your email address (#{Current.user.email_address})", "profile" => "Access your profile information", "groups" => "Access your group memberships", "offline_access" => "Stay signed in (refresh access)" } %>
|
|
||||||
<% @scopes.each do |scope| %>
|
|
||||||
<li class="flex items-start">
|
|
||||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300"><%= scope_labels[scope] || scope %></span>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
@@ -68,21 +58,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="text-center">
|
<%= render "terminal_state", state: @state %>
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
<%= @state == :expired ? "Code expired" : (@state == :already_handled ? "Code already used" : "Code not found") %>
|
|
||||||
</h2>
|
|
||||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<% if @state == :expired %>
|
|
||||||
This device code has expired. Start again from your tool to get a fresh code.
|
|
||||||
<% elsif @state == :already_handled %>
|
|
||||||
This device code has already been approved or denied. Start again from your tool if you need a new one.
|
|
||||||
<% else %>
|
|
||||||
We couldn't find that code. Check the code your tool is showing and try again.
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
<%= link_to "Enter a different code", device_verification_path, class: "mt-6 inline-block text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,40 +18,7 @@
|
|||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This application will be able to:</h3>
|
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This application will be able to:</h3>
|
||||||
<ul class="space-y-2">
|
<%= render "shared/scope_list", scopes: @scopes %>
|
||||||
<% if @scopes.include?("openid") %>
|
|
||||||
<li class="flex items-start">
|
|
||||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Verify your identity</span>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
<% if @scopes.include?("email") %>
|
|
||||||
<li class="flex items-start">
|
|
||||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your email address (<%= Current.session.user.email_address %>)</span>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
<% if @scopes.include?("profile") %>
|
|
||||||
<li class="flex items-start">
|
|
||||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your profile information</span>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
<% if @scopes.include?("groups") %>
|
|
||||||
<li class="flex items-start">
|
|
||||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your group memberships</span>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4 mb-6">
|
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4 mb-6">
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<%# Renders the "this will be able to..." list of granted OAuth scopes. Shared by
|
||||||
|
the browser consent screen and the device authorization screen so the scope
|
||||||
|
descriptions (see OidcHelper#scope_description) stay in one place.
|
||||||
|
Locals: scopes (Array<String>). %>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<% scopes.each do |scope| %>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"><%= scope_description(scope) %></span>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
@@ -97,6 +97,27 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_match(/error_description=.*code_challenge_method/, @response.location)
|
assert_match(/error_description=.*code_challenge_method/, @response.location)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "authorize error redirect preserves an existing query string in redirect_uri" do
|
||||||
|
redirect_with_query = "http://localhost:4000/callback?tenant=acme"
|
||||||
|
app = Application.create!(
|
||||||
|
name: "Query RU App", slug: "query-ru-app", app_type: "oidc",
|
||||||
|
redirect_uris: [redirect_with_query].to_json, active: true
|
||||||
|
)
|
||||||
|
grant_everyone_access(app)
|
||||||
|
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
response_type: "token", # unsupported → triggers an error redirect
|
||||||
|
client_id: app.client_id,
|
||||||
|
redirect_uri: redirect_with_query,
|
||||||
|
scope: "openid"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
# The error is appended with "&" onto the existing query, not a second "?".
|
||||||
|
assert_equal 1, @response.location.count("?"), "must not introduce a second question mark"
|
||||||
|
assert_match(%r{\Ahttp://localhost:4000/callback\?tenant=acme&error=unsupported_response_type}, @response.location)
|
||||||
|
end
|
||||||
|
|
||||||
test "authorization endpoint rejects invalid code_challenge format" do
|
test "authorization endpoint rejects invalid code_challenge format" do
|
||||||
# Contains + character which is not base64url
|
# Contains + character which is not base64url
|
||||||
auth_params = {
|
auth_params = {
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ class OidcRegistrationControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :created
|
assert_response :created
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "allows http redirect_uris for IPv6 loopback (RFC 8252)" do
|
||||||
|
enable_dcr
|
||||||
|
register(redirect_uris: ["http://[::1]:49152/callback"], token_endpoint_auth_method: "none")
|
||||||
|
assert_response :created
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows http redirect_uris for IPv4 loopback" do
|
||||||
|
enable_dcr
|
||||||
|
register(redirect_uris: ["http://127.0.0.1:49152/callback"], token_endpoint_auth_method: "none")
|
||||||
|
assert_response :created
|
||||||
|
end
|
||||||
|
|
||||||
test "rejects unsupported grant types" do
|
test "rejects unsupported grant types" do
|
||||||
enable_dcr
|
enable_dcr
|
||||||
register(redirect_uris: ["https://client.example.com/cb"], grant_types: ["client_credentials"])
|
register(redirect_uris: ["https://client.example.com/cb"], grant_types: ["client_credentials"])
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcHelperTest < ActionView::TestCase
|
||||||
|
test "scope_description returns a human-readable label for each supported scope" do
|
||||||
|
user = User.new(email_address: "person@example.com")
|
||||||
|
assert_equal "Verify your identity", scope_description("openid", user: user)
|
||||||
|
assert_equal "Access your email address (person@example.com)", scope_description("email", user: user)
|
||||||
|
assert_equal "Access your profile information", scope_description("profile", user: user)
|
||||||
|
assert_equal "Access your group memberships", scope_description("groups", user: user)
|
||||||
|
assert_equal "Stay signed in (refresh access)", scope_description("offline_access", user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "scope_description covers every SUPPORTED_SCOPE (so the consent screens can't silently drop one)" do
|
||||||
|
user = User.new(email_address: "person@example.com")
|
||||||
|
OidcController::SUPPORTED_SCOPES.each do |scope|
|
||||||
|
assert_not_equal scope, scope_description(scope, user: user),
|
||||||
|
"#{scope} has no description and would render as its raw name"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "scope_description falls back to the raw scope name for unknown scopes" do
|
||||||
|
assert_equal "somethingelse", scope_description("somethingelse", user: User.new)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user