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:
Dan Milne
2026-07-19 14:14:44 +10:00
co-authored by Claude Opus 4.8
parent 79a7524fda
commit f65f8da2e7
9 changed files with 118 additions and 104 deletions
@@ -97,6 +97,27 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
assert_match(/error_description=.*code_challenge_method/, @response.location)
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
# Contains + character which is not base64url
auth_params = {
@@ -82,6 +82,18 @@ class OidcRegistrationControllerTest < ActionDispatch::IntegrationTest
assert_response :created
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
enable_dcr
register(redirect_uris: ["https://client.example.com/cb"], grant_types: ["client_credentials"])
+24
View File
@@ -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