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
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "request_not_supported", description: "Request objects are not supported", state: state)
|
||||
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}"
|
||||
error_uri = "#{redirect_uri}?error=unsupported_response_type"
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "unsupported_response_type", description: "Only 'code' response_type is supported", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
# RFC 8707 §2: if a resource indicator is supplied it must be a valid target,
|
||||
# otherwise the request is rejected with error=invalid_target.
|
||||
if resource.present? && !valid_resource_indicator?(resource)
|
||||
error_uri = "#{redirect_uri}?error=invalid_target"
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "invalid_target", description: "resource must be an absolute URI without a fragment", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -234,20 +225,14 @@ class OidcController < ApplicationController
|
||||
if code_challenge.present?
|
||||
unless code_challenge_method == "S256"
|
||||
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge_method: only 'S256' is supported", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||
Rails.logger.error "OAuth: Invalid code_challenge format"
|
||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", state: state)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -267,10 +252,7 @@ class OidcController < ApplicationController
|
||||
# Validate claims parameter format if present
|
||||
if claims_parameter.present? && parsed_claims.nil?
|
||||
Rails.logger.error "OAuth: Invalid claims parameter format"
|
||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid claims parameter: must be valid JSON", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -279,10 +261,7 @@ class OidcController < ApplicationController
|
||||
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
|
||||
unless validation_result[:valid]
|
||||
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
|
||||
error_uri = "#{redirect_uri}?error=invalid_scope"
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "invalid_scope", description: "Claims parameter requests claims not covered by granted scopes", state: state)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -290,9 +269,7 @@ class OidcController < ApplicationController
|
||||
# Check if application is active (now we can safely redirect with error)
|
||||
unless @application.active?
|
||||
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||
error_uri = "#{redirect_uri}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(redirect_uri, "unauthorized_client", description: "Application is not active", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -302,9 +279,7 @@ class OidcController < ApplicationController
|
||||
# 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
|
||||
redirect_authorize_error(redirect_uri, "login_required", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -383,9 +358,7 @@ class OidcController < ApplicationController
|
||||
end
|
||||
|
||||
unless requested_scopes.include?("openid")
|
||||
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(redirect_uri, "invalid_scope", description: "The 'openid' scope is required", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -499,9 +472,7 @@ class OidcController < ApplicationController
|
||||
# User denied consent
|
||||
if params[:deny].present?
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(oauth_params["redirect_uri"], "access_denied", state: oauth_params["state"])
|
||||
return
|
||||
end
|
||||
|
||||
@@ -513,9 +484,7 @@ class OidcController < ApplicationController
|
||||
unless application&.active?
|
||||
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(oauth_params["redirect_uri"], "unauthorized_client", description: "Application is not active", state: oauth_params["state"])
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1382,6 +1351,19 @@ class OidcController < ApplicationController
|
||||
|
||||
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
|
||||
# invalid client_id be reported on-page, not via redirect.
|
||||
def set_application
|
||||
|
||||
@@ -120,7 +120,9 @@ class OidcRegistrationController < ApplicationController
|
||||
parsed = URI.parse(uri)
|
||||
return false unless parsed.is_a?(URI::HTTP) # covers HTTP and 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
|
||||
false
|
||||
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? %>
|
||||
<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>
|
||||
<ul class="space-y-2">
|
||||
<% 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>
|
||||
<%= render "shared/scope_list", scopes: @scopes %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -68,21 +58,7 @@
|
||||
<% end %>
|
||||
|
||||
<% else %>
|
||||
<div class="text-center">
|
||||
<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>
|
||||
<%= render "terminal_state", state: @state %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,40 +18,7 @@
|
||||
|
||||
<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>
|
||||
<ul class="space-y-2">
|
||||
<% 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>
|
||||
<%= render "shared/scope_list", scopes: @scopes %>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user